From 367296e98406b23780d8bbbe4be547748232a90b Mon Sep 17 00:00:00 2001 From: vivet Date: Tue, 6 Oct 2026 12:04:23 +0200 Subject: [PATCH 1/4] Updated Skills, Prompts and AGENTS.md --- .github/workflows/build-and-deploy.yml | 2 +- .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.ApiClients.Audit/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.ApiClients.Entity/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.ApiClients.RootLogIn/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.ApiClients/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Auth.External.Custom/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Auth.External.Microsoft/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Auth.RootLogin/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Authorization/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.ContentNegotiation/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Cookies/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.CustomConfigSection/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.CustomMiddleware/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.CustomService/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Data.Audit/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Data.EntityEvents/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Data.Identity.Auth.ApiKey/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- .../AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Data.Identity.Auth.Jwt/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Data.Identity/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Data.InMemory/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Data.LazyLoading/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Data.MySql.Collation/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Data.MySql.Mappings/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Data.MySql.Spatial/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Data.MySql.StoredProcedures/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Data.MySql.Views/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Data.MySql/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Data.PostgreSQL.Spatial/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Data.PostgreSQL/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Data.Repository.AutoSave/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Data.Repository.Includes/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Data.SoftDelete/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Data.SqLite/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Data.SqlServer.Spatial/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Data.SqlServer/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Data.Triggers/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Documentation.Csp/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Documentation/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.ErrorHandling/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Eventing.RabbitMq/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.HealthChecks/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Hosting.Http/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Hosting.Https/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Hosting.MultipartLimits/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Localization/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Logging.Log4Net/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Logging.Microsoft/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Logging.NLog/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Logging.Serilog/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Metrics/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.MultipartJson/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- .../AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.PolicyHeaders.ContentTypeOptions/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.PolicyHeaders.Cors/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.PolicyHeaders.ForwardedHeaders/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.PolicyHeaders.FrameOptions/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.PolicyHeaders.Hsts/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.PolicyHeaders.ReferrerPolicy/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.PolicyHeaders.Robots/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.PolicyHeaders.XssProtection/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.RequestTracing/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.ResponseCache/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.ResponseCompression/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Session/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.StartupTasks/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.StaticFiles/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Storage.Azure/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Storage.Local/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.TimeZone/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.Versioning/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api.VirusScan/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Api._Blank/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Console.CustomConfigSection/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Console.CustomService/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Console.Data.InMemory/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Console.Data.MySql/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Console.Data.PostgreSQL/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Console.Data.SqLite/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Console.Data.SqlServer/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Console.Eventing.RabbitMq/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Console.ExceptionHandling/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Console.Localization/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Console.Logging.Log4Net/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Console.Logging.Microsoft/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Console.Logging.NLog/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Console.Logging.Serilog/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Console.StartupTasks/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Console.Storage.Azure/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Console.Storage.Local/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Console.Workers/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Console._Blank/AGENTS.md | 10 ++++++++++ .../.claude/skills/nano-add-entity/SKILL.md | 17 +++++++++++++++-- .../.github/prompts/nano-add-entity.prompt.md | 17 +++++++++++++++-- Web._Blank/AGENTS.md | 10 ++++++++++ 277 files changed, 3681 insertions(+), 369 deletions(-) diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 8984f169..9e0f7292 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -7,7 +7,7 @@ on: branches: - master env: - VERSION: 10.0.8 + VERSION: 10.0.9 jobs: build-and-deploy: runs-on: ubuntu-latest diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-entity/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-entity.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.ApiClients.Audit/AGENTS.md b/Api.ApiClients.Audit/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.ApiClients.Audit/AGENTS.md +++ b/Api.ApiClients.Audit/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-entity/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-entity.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.ApiClients.Entity/AGENTS.md b/Api.ApiClients.Entity/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.ApiClients.Entity/AGENTS.md +++ b/Api.ApiClients.Entity/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-entity/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-entity.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.ApiClients.RootLogIn/AGENTS.md b/Api.ApiClients.RootLogIn/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.ApiClients.RootLogIn/AGENTS.md +++ b/Api.ApiClients.RootLogIn/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md b/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.ApiClients/AGENTS.md b/Api.ApiClients/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.ApiClients/AGENTS.md +++ b/Api.ApiClients/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Auth.External.Custom/AGENTS.md b/Api.Auth.External.Custom/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Auth.External.Custom/AGENTS.md +++ b/Api.Auth.External.Custom/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-entity/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Auth.External.Microsoft/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-entity.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Auth.External.Microsoft/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Auth.External.Microsoft/AGENTS.md b/Api.Auth.External.Microsoft/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Auth.External.Microsoft/AGENTS.md +++ b/Api.Auth.External.Microsoft/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Auth.RootLogin/.claude/skills/nano-add-entity/SKILL.md b/Api.Auth.RootLogin/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Auth.RootLogin/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Auth.RootLogin/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Auth.RootLogin/.github/prompts/nano-add-entity.prompt.md b/Api.Auth.RootLogin/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Auth.RootLogin/AGENTS.md b/Api.Auth.RootLogin/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Auth.RootLogin/AGENTS.md +++ b/Api.Auth.RootLogin/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Authorization/.claude/skills/nano-add-entity/SKILL.md b/Api.Authorization/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Authorization/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Authorization/.github/prompts/nano-add-entity.prompt.md b/Api.Authorization/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Authorization/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Authorization/AGENTS.md b/Api.Authorization/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Authorization/AGENTS.md +++ b/Api.Authorization/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.ContentNegotiation/.claude/skills/nano-add-entity/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-entity.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.ContentNegotiation/AGENTS.md b/Api.ContentNegotiation/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.ContentNegotiation/AGENTS.md +++ b/Api.ContentNegotiation/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Cookies/.claude/skills/nano-add-entity/SKILL.md b/Api.Cookies/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Cookies/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Cookies/.github/prompts/nano-add-entity.prompt.md b/Api.Cookies/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Cookies/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Cookies/AGENTS.md b/Api.Cookies/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Cookies/AGENTS.md +++ b/Api.Cookies/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.CustomConfigSection/AGENTS.md b/Api.CustomConfigSection/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.CustomConfigSection/AGENTS.md +++ b/Api.CustomConfigSection/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.CustomMiddleware/.claude/skills/nano-add-entity/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-entity.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-entity.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.CustomMiddleware/AGENTS.md b/Api.CustomMiddleware/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.CustomMiddleware/AGENTS.md +++ b/Api.CustomMiddleware/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.CustomService/.claude/skills/nano-add-entity/SKILL.md b/Api.CustomService/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.CustomService/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.CustomService/.github/prompts/nano-add-entity.prompt.md b/Api.CustomService/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.CustomService/.github/prompts/nano-add-entity.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.CustomService/AGENTS.md b/Api.CustomService/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.CustomService/AGENTS.md +++ b/Api.CustomService/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Data.Audit/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.Audit/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Data.Audit/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Audit/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.Audit/.github/prompts/nano-add-entity.prompt.md b/Api.Data.Audit/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Data.Audit/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.Audit/AGENTS.md b/Api.Data.Audit/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Data.Audit/AGENTS.md +++ b/Api.Data.Audit/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Data.EntityEvents/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.EntityEvents/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Data.EntityEvents/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.EntityEvents/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.EntityEvents/.github/prompts/nano-add-entity.prompt.md b/Api.Data.EntityEvents/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.EntityEvents/AGENTS.md b/Api.Data.EntityEvents/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Data.EntityEvents/AGENTS.md +++ b/Api.Data.EntityEvents/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-entity.prompt.md b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.Identity.Auth.ApiKey/AGENTS.md b/Api.Data.Identity.Auth.ApiKey/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Data.Identity.Auth.ApiKey/AGENTS.md +++ b/Api.Data.Identity.Auth.ApiKey/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.Identity.Auth.External.Custom/AGENTS.md b/Api.Data.Identity.Auth.External.Custom/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Data.Identity.Auth.External.Custom/AGENTS.md +++ b/Api.Data.Identity.Auth.External.Custom/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-entity.prompt.md b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.Identity.Auth.Jwt/AGENTS.md b/Api.Data.Identity.Auth.Jwt/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Data.Identity.Auth.Jwt/AGENTS.md +++ b/Api.Data.Identity.Auth.Jwt/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Data.Identity/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.Identity/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Data.Identity/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Identity/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.Identity/.github/prompts/nano-add-entity.prompt.md b/Api.Data.Identity/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Data.Identity/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.Identity/AGENTS.md b/Api.Data.Identity/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Data.Identity/AGENTS.md +++ b/Api.Data.Identity/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.InMemory/.github/prompts/nano-add-entity.prompt.md b/Api.Data.InMemory/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Data.InMemory/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.InMemory/AGENTS.md b/Api.Data.InMemory/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Data.InMemory/AGENTS.md +++ b/Api.Data.InMemory/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Data.LazyLoading/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.LazyLoading/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Data.LazyLoading/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.LazyLoading/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.LazyLoading/.github/prompts/nano-add-entity.prompt.md b/Api.Data.LazyLoading/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.LazyLoading/AGENTS.md b/Api.Data.LazyLoading/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Data.LazyLoading/AGENTS.md +++ b/Api.Data.LazyLoading/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Data.MySql.Collation/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.MySql.Collation/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Data.MySql.Collation/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.MySql.Collation/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.MySql.Collation/.github/prompts/nano-add-entity.prompt.md b/Api.Data.MySql.Collation/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Data.MySql.Collation/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.MySql.Collation/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.MySql.Collation/AGENTS.md b/Api.Data.MySql.Collation/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Data.MySql.Collation/AGENTS.md +++ b/Api.Data.MySql.Collation/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Data.MySql.Mappings/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.MySql.Mappings/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Data.MySql.Mappings/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.MySql.Mappings/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.MySql.Mappings/.github/prompts/nano-add-entity.prompt.md b/Api.Data.MySql.Mappings/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Data.MySql.Mappings/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.MySql.Mappings/AGENTS.md b/Api.Data.MySql.Mappings/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Data.MySql.Mappings/AGENTS.md +++ b/Api.Data.MySql.Mappings/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Data.MySql.Spatial/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.MySql.Spatial/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Data.MySql.Spatial/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.MySql.Spatial/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.MySql.Spatial/.github/prompts/nano-add-entity.prompt.md b/Api.Data.MySql.Spatial/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Data.MySql.Spatial/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.MySql.Spatial/AGENTS.md b/Api.Data.MySql.Spatial/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Data.MySql.Spatial/AGENTS.md +++ b/Api.Data.MySql.Spatial/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-entity.prompt.md b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.MySql.StoredProcedures/AGENTS.md b/Api.Data.MySql.StoredProcedures/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Data.MySql.StoredProcedures/AGENTS.md +++ b/Api.Data.MySql.StoredProcedures/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Data.MySql.Views/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.MySql.Views/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Data.MySql.Views/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.MySql.Views/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.MySql.Views/.github/prompts/nano-add-entity.prompt.md b/Api.Data.MySql.Views/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Data.MySql.Views/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.MySql.Views/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.MySql.Views/AGENTS.md b/Api.Data.MySql.Views/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Data.MySql.Views/AGENTS.md +++ b/Api.Data.MySql.Views/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Data.MySql/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.MySql/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Data.MySql/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.MySql/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.MySql/.github/prompts/nano-add-entity.prompt.md b/Api.Data.MySql/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Data.MySql/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.MySql/AGENTS.md b/Api.Data.MySql/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Data.MySql/AGENTS.md +++ b/Api.Data.MySql/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-entity.prompt.md b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.PostgreSQL.Spatial/AGENTS.md b/Api.Data.PostgreSQL.Spatial/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Data.PostgreSQL.Spatial/AGENTS.md +++ b/Api.Data.PostgreSQL.Spatial/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md b/Api.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.PostgreSQL/AGENTS.md b/Api.Data.PostgreSQL/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Data.PostgreSQL/AGENTS.md +++ b/Api.Data.PostgreSQL/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-entity.prompt.md b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.Repository.AutoSave/AGENTS.md b/Api.Data.Repository.AutoSave/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Data.Repository.AutoSave/AGENTS.md +++ b/Api.Data.Repository.AutoSave/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Data.Repository.Includes/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.Repository.Includes/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Data.Repository.Includes/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Repository.Includes/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.Repository.Includes/.github/prompts/nano-add-entity.prompt.md b/Api.Data.Repository.Includes/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Data.Repository.Includes/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Repository.Includes/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.Repository.Includes/AGENTS.md b/Api.Data.Repository.Includes/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Data.Repository.Includes/AGENTS.md +++ b/Api.Data.Repository.Includes/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Data.SoftDelete/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.SoftDelete/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Data.SoftDelete/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.SoftDelete/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.SoftDelete/.github/prompts/nano-add-entity.prompt.md b/Api.Data.SoftDelete/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.SoftDelete/AGENTS.md b/Api.Data.SoftDelete/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Data.SoftDelete/AGENTS.md +++ b/Api.Data.SoftDelete/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.SqLite/.github/prompts/nano-add-entity.prompt.md b/Api.Data.SqLite/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Data.SqLite/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.SqLite/AGENTS.md b/Api.Data.SqLite/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Data.SqLite/AGENTS.md +++ b/Api.Data.SqLite/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-entity.prompt.md b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.SqlServer.Spatial/AGENTS.md b/Api.Data.SqlServer.Spatial/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Data.SqlServer.Spatial/AGENTS.md +++ b/Api.Data.SqlServer.Spatial/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md b/Api.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.SqlServer/AGENTS.md b/Api.Data.SqlServer/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Data.SqlServer/AGENTS.md +++ b/Api.Data.SqlServer/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Data.Triggers/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.Triggers/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Data.Triggers/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Triggers/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.Triggers/.github/prompts/nano-add-entity.prompt.md b/Api.Data.Triggers/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Data.Triggers/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Data.Triggers/AGENTS.md b/Api.Data.Triggers/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Data.Triggers/AGENTS.md +++ b/Api.Data.Triggers/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Documentation.Csp/.claude/skills/nano-add-entity/SKILL.md b/Api.Documentation.Csp/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Documentation.Csp/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Documentation.Csp/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Documentation.Csp/.github/prompts/nano-add-entity.prompt.md b/Api.Documentation.Csp/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Documentation.Csp/AGENTS.md b/Api.Documentation.Csp/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Documentation.Csp/AGENTS.md +++ b/Api.Documentation.Csp/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Documentation/.claude/skills/nano-add-entity/SKILL.md b/Api.Documentation/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Documentation/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Documentation/.github/prompts/nano-add-entity.prompt.md b/Api.Documentation/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Documentation/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Documentation/AGENTS.md b/Api.Documentation/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Documentation/AGENTS.md +++ b/Api.Documentation/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.ErrorHandling/.claude/skills/nano-add-entity/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.ErrorHandling/.github/prompts/nano-add-entity.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.ErrorHandling/AGENTS.md b/Api.ErrorHandling/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.ErrorHandling/AGENTS.md +++ b/Api.ErrorHandling/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md b/Api.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md b/Api.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Eventing.RabbitMq/AGENTS.md b/Api.Eventing.RabbitMq/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Eventing.RabbitMq/AGENTS.md +++ b/Api.Eventing.RabbitMq/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.HealthChecks/.claude/skills/nano-add-entity/SKILL.md b/Api.HealthChecks/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.HealthChecks/.github/prompts/nano-add-entity.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-entity.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.HealthChecks/AGENTS.md b/Api.HealthChecks/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.HealthChecks/AGENTS.md +++ b/Api.HealthChecks/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Hosting.Http/.claude/skills/nano-add-entity/SKILL.md b/Api.Hosting.Http/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Hosting.Http/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Hosting.Http/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Hosting.Http/.github/prompts/nano-add-entity.prompt.md b/Api.Hosting.Http/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Hosting.Http/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Hosting.Http/AGENTS.md b/Api.Hosting.Http/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Hosting.Http/AGENTS.md +++ b/Api.Hosting.Http/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Hosting.Https/.claude/skills/nano-add-entity/SKILL.md b/Api.Hosting.Https/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Hosting.Https/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Hosting.Https/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Hosting.Https/.github/prompts/nano-add-entity.prompt.md b/Api.Hosting.Https/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Hosting.Https/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Hosting.Https/AGENTS.md b/Api.Hosting.Https/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Hosting.Https/AGENTS.md +++ b/Api.Hosting.Https/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-entity/SKILL.md b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-entity.prompt.md b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Hosting.MultipartLimits/AGENTS.md b/Api.Hosting.MultipartLimits/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Hosting.MultipartLimits/AGENTS.md +++ b/Api.Hosting.MultipartLimits/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Localization/.claude/skills/nano-add-entity/SKILL.md b/Api.Localization/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Localization/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Localization/.github/prompts/nano-add-entity.prompt.md b/Api.Localization/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Localization/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Localization/AGENTS.md b/Api.Localization/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Localization/AGENTS.md +++ b/Api.Localization/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md b/Api.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md b/Api.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Logging.Log4Net/AGENTS.md b/Api.Logging.Log4Net/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Logging.Log4Net/AGENTS.md +++ b/Api.Logging.Log4Net/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md b/Api.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md b/Api.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Logging.Microsoft/AGENTS.md b/Api.Logging.Microsoft/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Logging.Microsoft/AGENTS.md +++ b/Api.Logging.Microsoft/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md b/Api.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Logging.NLog/.github/prompts/nano-add-entity.prompt.md b/Api.Logging.NLog/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Logging.NLog/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Logging.NLog/AGENTS.md b/Api.Logging.NLog/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Logging.NLog/AGENTS.md +++ b/Api.Logging.NLog/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md b/Api.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md b/Api.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Logging.Serilog/AGENTS.md b/Api.Logging.Serilog/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Logging.Serilog/AGENTS.md +++ b/Api.Logging.Serilog/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Metrics/.claude/skills/nano-add-entity/SKILL.md b/Api.Metrics/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Metrics/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Metrics/.github/prompts/nano-add-entity.prompt.md b/Api.Metrics/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Metrics/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Metrics/AGENTS.md b/Api.Metrics/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Metrics/AGENTS.md +++ b/Api.Metrics/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.MultipartJson/.claude/skills/nano-add-entity/SKILL.md b/Api.MultipartJson/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.MultipartJson/.github/prompts/nano-add-entity.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-entity.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.MultipartJson/AGENTS.md b/Api.MultipartJson/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.MultipartJson/AGENTS.md +++ b/Api.MultipartJson/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md +++ b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.PolicyHeaders.Cors/AGENTS.md b/Api.PolicyHeaders.Cors/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.PolicyHeaders.Cors/AGENTS.md +++ b/Api.PolicyHeaders.Cors/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md +++ b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.PolicyHeaders.FrameOptions/AGENTS.md b/Api.PolicyHeaders.FrameOptions/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.PolicyHeaders.FrameOptions/AGENTS.md +++ b/Api.PolicyHeaders.FrameOptions/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.PolicyHeaders.Hsts/AGENTS.md b/Api.PolicyHeaders.Hsts/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.PolicyHeaders.Hsts/AGENTS.md +++ b/Api.PolicyHeaders.Hsts/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.PolicyHeaders.Robots/AGENTS.md b/Api.PolicyHeaders.Robots/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.PolicyHeaders.Robots/AGENTS.md +++ b/Api.PolicyHeaders.Robots/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.PolicyHeaders.XssProtection/AGENTS.md b/Api.PolicyHeaders.XssProtection/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.PolicyHeaders.XssProtection/AGENTS.md +++ b/Api.PolicyHeaders.XssProtection/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.RequestTracing/.claude/skills/nano-add-entity/SKILL.md b/Api.RequestTracing/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.RequestTracing/.github/prompts/nano-add-entity.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-entity.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.RequestTracing/AGENTS.md b/Api.RequestTracing/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.RequestTracing/AGENTS.md +++ b/Api.RequestTracing/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.ResponseCache/.claude/skills/nano-add-entity/SKILL.md b/Api.ResponseCache/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.ResponseCache/.github/prompts/nano-add-entity.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.ResponseCache/AGENTS.md b/Api.ResponseCache/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.ResponseCache/AGENTS.md +++ b/Api.ResponseCache/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.ResponseCompression/.claude/skills/nano-add-entity/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.ResponseCompression/.github/prompts/nano-add-entity.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.ResponseCompression/AGENTS.md b/Api.ResponseCompression/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.ResponseCompression/AGENTS.md +++ b/Api.ResponseCompression/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Session/.claude/skills/nano-add-entity/SKILL.md b/Api.Session/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Session/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Session/.github/prompts/nano-add-entity.prompt.md b/Api.Session/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Session/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Session/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Session/AGENTS.md b/Api.Session/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Session/AGENTS.md +++ b/Api.Session/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.StartupTasks/.claude/skills/nano-add-entity/SKILL.md b/Api.StartupTasks/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.StartupTasks/.github/prompts/nano-add-entity.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-entity.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.StartupTasks/AGENTS.md b/Api.StartupTasks/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.StartupTasks/AGENTS.md +++ b/Api.StartupTasks/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.StaticFiles/.claude/skills/nano-add-entity/SKILL.md b/Api.StaticFiles/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.StaticFiles/.github/prompts/nano-add-entity.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-entity.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.StaticFiles/AGENTS.md b/Api.StaticFiles/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.StaticFiles/AGENTS.md +++ b/Api.StaticFiles/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md b/Api.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Storage.Azure/.github/prompts/nano-add-entity.prompt.md b/Api.Storage.Azure/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Storage.Azure/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Storage.Azure/AGENTS.md b/Api.Storage.Azure/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Storage.Azure/AGENTS.md +++ b/Api.Storage.Azure/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Storage.Local/.claude/skills/nano-add-entity/SKILL.md b/Api.Storage.Local/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Storage.Local/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Storage.Local/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Storage.Local/.github/prompts/nano-add-entity.prompt.md b/Api.Storage.Local/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Storage.Local/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Storage.Local/AGENTS.md b/Api.Storage.Local/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Storage.Local/AGENTS.md +++ b/Api.Storage.Local/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.TimeZone/.claude/skills/nano-add-entity/SKILL.md b/Api.TimeZone/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.TimeZone/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.TimeZone/.github/prompts/nano-add-entity.prompt.md b/Api.TimeZone/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.TimeZone/.github/prompts/nano-add-entity.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.TimeZone/AGENTS.md b/Api.TimeZone/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.TimeZone/AGENTS.md +++ b/Api.TimeZone/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.Versioning/.claude/skills/nano-add-entity/SKILL.md b/Api.Versioning/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.Versioning/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Versioning/.github/prompts/nano-add-entity.prompt.md b/Api.Versioning/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.Versioning/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.Versioning/AGENTS.md b/Api.Versioning/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.Versioning/AGENTS.md +++ b/Api.Versioning/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api.VirusScan/.claude/skills/nano-add-entity/SKILL.md b/Api.VirusScan/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api.VirusScan/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.VirusScan/.github/prompts/nano-add-entity.prompt.md b/Api.VirusScan/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api.VirusScan/.github/prompts/nano-add-entity.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api.VirusScan/AGENTS.md b/Api.VirusScan/AGENTS.md index d29b576d..58600e18 100644 --- a/Api.VirusScan/AGENTS.md +++ b/Api.VirusScan/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Api._Blank/.claude/skills/nano-add-entity/SKILL.md b/Api._Blank/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Api._Blank/.claude/skills/nano-add-entity/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api._Blank/.github/prompts/nano-add-entity.prompt.md b/Api._Blank/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Api._Blank/.github/prompts/nano-add-entity.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Api._Blank/AGENTS.md b/Api._Blank/AGENTS.md index d29b576d..58600e18 100644 --- a/Api._Blank/AGENTS.md +++ b/Api._Blank/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Console.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.CustomConfigSection/AGENTS.md b/Console.CustomConfigSection/AGENTS.md index d29b576d..58600e18 100644 --- a/Console.CustomConfigSection/AGENTS.md +++ b/Console.CustomConfigSection/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Console.CustomService/.claude/skills/nano-add-entity/SKILL.md b/Console.CustomService/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Console.CustomService/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.CustomService/.github/prompts/nano-add-entity.prompt.md b/Console.CustomService/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Console.CustomService/.github/prompts/nano-add-entity.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.CustomService/AGENTS.md b/Console.CustomService/AGENTS.md index d29b576d..58600e18 100644 --- a/Console.CustomService/AGENTS.md +++ b/Console.CustomService/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Console.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md b/Console.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Console.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Data.InMemory/.github/prompts/nano-add-entity.prompt.md b/Console.Data.InMemory/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Console.Data.InMemory/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Data.InMemory/AGENTS.md b/Console.Data.InMemory/AGENTS.md index d29b576d..58600e18 100644 --- a/Console.Data.InMemory/AGENTS.md +++ b/Console.Data.InMemory/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Console.Data.MySql/.claude/skills/nano-add-entity/SKILL.md b/Console.Data.MySql/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Console.Data.MySql/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.MySql/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Data.MySql/.github/prompts/nano-add-entity.prompt.md b/Console.Data.MySql/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Console.Data.MySql/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Data.MySql/AGENTS.md b/Console.Data.MySql/AGENTS.md index d29b576d..58600e18 100644 --- a/Console.Data.MySql/AGENTS.md +++ b/Console.Data.MySql/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Console.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md b/Console.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Console.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md b/Console.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Data.PostgreSQL/AGENTS.md b/Console.Data.PostgreSQL/AGENTS.md index d29b576d..58600e18 100644 --- a/Console.Data.PostgreSQL/AGENTS.md +++ b/Console.Data.PostgreSQL/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Console.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md b/Console.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Console.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Data.SqLite/.github/prompts/nano-add-entity.prompt.md b/Console.Data.SqLite/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Console.Data.SqLite/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Data.SqLite/AGENTS.md b/Console.Data.SqLite/AGENTS.md index d29b576d..58600e18 100644 --- a/Console.Data.SqLite/AGENTS.md +++ b/Console.Data.SqLite/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Console.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md b/Console.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Console.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md b/Console.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Data.SqlServer/AGENTS.md b/Console.Data.SqlServer/AGENTS.md index d29b576d..58600e18 100644 --- a/Console.Data.SqlServer/AGENTS.md +++ b/Console.Data.SqlServer/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Console.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md b/Console.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Console.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md b/Console.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Eventing.RabbitMq/AGENTS.md b/Console.Eventing.RabbitMq/AGENTS.md index d29b576d..58600e18 100644 --- a/Console.Eventing.RabbitMq/AGENTS.md +++ b/Console.Eventing.RabbitMq/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Console.ExceptionHandling/.claude/skills/nano-add-entity/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-entity.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-entity.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.ExceptionHandling/AGENTS.md b/Console.ExceptionHandling/AGENTS.md index d29b576d..58600e18 100644 --- a/Console.ExceptionHandling/AGENTS.md +++ b/Console.ExceptionHandling/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Console.Localization/.claude/skills/nano-add-entity/SKILL.md b/Console.Localization/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Console.Localization/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Localization/.github/prompts/nano-add-entity.prompt.md b/Console.Localization/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Console.Localization/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Localization/AGENTS.md b/Console.Localization/AGENTS.md index d29b576d..58600e18 100644 --- a/Console.Localization/AGENTS.md +++ b/Console.Localization/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Console.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md b/Console.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Console.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md b/Console.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Logging.Log4Net/AGENTS.md b/Console.Logging.Log4Net/AGENTS.md index d29b576d..58600e18 100644 --- a/Console.Logging.Log4Net/AGENTS.md +++ b/Console.Logging.Log4Net/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Console.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md b/Console.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Console.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md b/Console.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Logging.Microsoft/AGENTS.md b/Console.Logging.Microsoft/AGENTS.md index d29b576d..58600e18 100644 --- a/Console.Logging.Microsoft/AGENTS.md +++ b/Console.Logging.Microsoft/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Console.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md b/Console.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Console.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Logging.NLog/.github/prompts/nano-add-entity.prompt.md b/Console.Logging.NLog/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Console.Logging.NLog/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Logging.NLog/AGENTS.md b/Console.Logging.NLog/AGENTS.md index d29b576d..58600e18 100644 --- a/Console.Logging.NLog/AGENTS.md +++ b/Console.Logging.NLog/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Console.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md b/Console.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Console.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md b/Console.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Logging.Serilog/AGENTS.md b/Console.Logging.Serilog/AGENTS.md index d29b576d..58600e18 100644 --- a/Console.Logging.Serilog/AGENTS.md +++ b/Console.Logging.Serilog/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Console.StartupTasks/.claude/skills/nano-add-entity/SKILL.md b/Console.StartupTasks/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.StartupTasks/.github/prompts/nano-add-entity.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-entity.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.StartupTasks/AGENTS.md b/Console.StartupTasks/AGENTS.md index d29b576d..58600e18 100644 --- a/Console.StartupTasks/AGENTS.md +++ b/Console.StartupTasks/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Console.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md b/Console.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Console.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Storage.Azure/.github/prompts/nano-add-entity.prompt.md b/Console.Storage.Azure/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Console.Storage.Azure/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Storage.Azure/AGENTS.md b/Console.Storage.Azure/AGENTS.md index d29b576d..58600e18 100644 --- a/Console.Storage.Azure/AGENTS.md +++ b/Console.Storage.Azure/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Console.Storage.Local/.claude/skills/nano-add-entity/SKILL.md b/Console.Storage.Local/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Console.Storage.Local/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Storage.Local/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Storage.Local/.github/prompts/nano-add-entity.prompt.md b/Console.Storage.Local/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Console.Storage.Local/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Storage.Local/AGENTS.md b/Console.Storage.Local/AGENTS.md index d29b576d..58600e18 100644 --- a/Console.Storage.Local/AGENTS.md +++ b/Console.Storage.Local/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Console.Workers/.claude/skills/nano-add-entity/SKILL.md b/Console.Workers/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Console.Workers/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Workers/.github/prompts/nano-add-entity.prompt.md b/Console.Workers/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Console.Workers/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console.Workers/AGENTS.md b/Console.Workers/AGENTS.md index d29b576d..58600e18 100644 --- a/Console.Workers/AGENTS.md +++ b/Console.Workers/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Console._Blank/.claude/skills/nano-add-entity/SKILL.md b/Console._Blank/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Console._Blank/.claude/skills/nano-add-entity/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console._Blank/.github/prompts/nano-add-entity.prompt.md b/Console._Blank/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Console._Blank/.github/prompts/nano-add-entity.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Console._Blank/AGENTS.md b/Console._Blank/AGENTS.md index d29b576d..58600e18 100644 --- a/Console._Blank/AGENTS.md +++ b/Console._Blank/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the diff --git a/Web._Blank/.claude/skills/nano-add-entity/SKILL.md b/Web._Blank/.claude/skills/nano-add-entity/SKILL.md index cb94fd63..11db2020 100644 --- a/Web._Blank/.claude/skills/nano-add-entity/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-entity/SKILL.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Web._Blank/.github/prompts/nano-add-entity.prompt.md b/Web._Blank/.github/prompts/nano-add-entity.prompt.md index f31fd49d..53f98eb1 100644 --- a/Web._Blank/.github/prompts/nano-add-entity.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-entity.prompt.md @@ -205,6 +205,16 @@ public class Mapping : BaseEntityMapping<> parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` everywhere just because it's often EF's own inferred behavior for required FKs. + - **Never give the FK scalar its own `.Property(...)` block.** `.HasForeignKey(...)` together with + `.IsRequired()` on the relationship already configures it (its nullability follows the property's + type), so a separate `.Property(x => x.ParentId).IsRequired();` (or a bare `.Property(x => x.ParentId);`) + is noise. The FK property is still declared on the entity in File 1. + - **Never leave `.WithMany()`/`.WithOne()` empty.** Every relationship has a navigation on both ends: if + the principal entity has no inverse collection/reference yet, add it to the principal's File 1 (for + example `Registrations` on the `Organization` that a `Registration` points at, or `SubmittedProducts` + when the reference is named `SubmittedByOrganization`), name the relationship in both mappings, and add + the principal-side `.HasMany(...).WithOne(...)` block described below. Don't skip the inverse because it + "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` @@ -245,8 +255,11 @@ public class Mapping : BaseEntityMapping<> `BaseEntityCreatableAndDeletableController` in File 4. - **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword - search reaches, declared at the end of `Configure`, after the properties and relationships. Foreign keys - already get an index from EF's conventions, so don't repeat those unless a composite covers them better; + search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end + of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + property's relationship block if it is a foreign key). Foreign keys already get an index from EF's + conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a + composite index or unique index already starts with it; don't index a low-selectivity flag (a plain `bool` or status on its own). When a list is always filtered by one property and sorted by another (a foreign key, newest first), use one composite index in that order (`HasIndex(x => new { x.ParentId, x.CreatedAt })`), which replaces a plain single-column index on diff --git a/Web._Blank/AGENTS.md b/Web._Blank/AGENTS.md index d29b576d..58600e18 100644 --- a/Web._Blank/AGENTS.md +++ b/Web._Blank/AGENTS.md @@ -2873,6 +2873,16 @@ public class MyEntityMapping : BaseEntityMapping ⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on it running before your own configuration. +⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end +of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and +skips it when a composite or unique index already starts with that property. + +⚠ Don't map a foreign key property on its own (`builder.Property(x => x.ParentId).IsRequired()`): the relationship's +`.HasForeignKey(...)` and `.IsRequired()` already configure it. And never leave `.WithMany()`/`.WithOne()` empty: +give the principal entity the inverse navigation (collection or reference) and name it in the relationship, so both +ends are explicit. + #### Auto-discovery mechanics `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the From 589b0e936b16005236682d8a96b91e6ee9f3f2e1 Mon Sep 17 00:00:00 2001 From: vivet Date: Wed, 7 Oct 2026 19:37:37 +0200 Subject: [PATCH 2/4] Updated --- .../Extensions/OwnedNavigationBuilderExtensions.cs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings/Data/Mappings/Extensions/OwnedNavigationBuilderExtensions.cs b/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings/Data/Mappings/Extensions/OwnedNavigationBuilderExtensions.cs index 65e6a37e..4f29cc34 100644 --- a/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings/Data/Mappings/Extensions/OwnedNavigationBuilderExtensions.cs +++ b/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings/Data/Mappings/Extensions/OwnedNavigationBuilderExtensions.cs @@ -11,13 +11,6 @@ namespace Api.Data.MySql.Mappings.Data.Mappings.Extensions; /// public static class OwnedNavigationBuilderExtensions { - /// - /// Maps for the owned by . - /// - /// The entity type. - /// The related entity type. - /// The . - /// The . internal static void MapType(this OwnedNavigationBuilder builder, Expression> expression) where TEntity : class where TRelatedEntity : class @@ -38,13 +31,6 @@ internal static void MapType(this OwnedNavigationBuilde .Property(x => x.Path); } - /// - /// Maps for the owned by . - /// - /// The entity type. - /// The related entity type. - /// The . - /// The . internal static void MapType(this OwnedNavigationBuilder builder, Expression> expression) where TEntity : class where TRelatedEntity : class From 839dfaa81165d524d638b7e0d158a30d7d0e2ab2 Mon Sep 17 00:00:00 2001 From: vivet Date: Wed, 7 Oct 2026 19:43:16 +0200 Subject: [PATCH 3/4] Updated --- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.ApiClients.Audit/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.ApiClients.Entity/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.ApiClients.RootLogIn/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.ApiClients/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Auth.External.Custom/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Auth.External.Microsoft/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Auth.RootLogin/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Authorization/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.ContentNegotiation/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Cookies/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.CustomConfigSection/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.CustomMiddleware/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.CustomService/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Data.Audit/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Data.EntityEvents/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Data.Identity.Auth.ApiKey/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- .../AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Data.Identity.Auth.Jwt/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Data.Identity/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Data.InMemory/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Data.LazyLoading/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Data.MySql.Collation/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Data.MySql.Mappings/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Data.MySql.Spatial/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Data.MySql.StoredProcedures/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Data.MySql.Views/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Data.MySql/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Data.PostgreSQL.Spatial/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Data.PostgreSQL/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Data.Repository.AutoSave/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Data.Repository.Includes/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Data.SoftDelete/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Data.SqLite/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Data.SqlServer.Spatial/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Data.SqlServer/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Data.Triggers/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Documentation.Csp/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Documentation/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.ErrorHandling/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Eventing.RabbitMq/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.HealthChecks/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Hosting.Http/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Hosting.Https/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Hosting.MultipartLimits/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Localization/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Logging.Log4Net/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Logging.Microsoft/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Logging.NLog/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Logging.Serilog/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Metrics/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.MultipartJson/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- .../AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- .../AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.PolicyHeaders.Cors/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.PolicyHeaders.ForwardedHeaders/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.PolicyHeaders.FrameOptions/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.PolicyHeaders.Hsts/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.PolicyHeaders.ReferrerPolicy/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.PolicyHeaders.Robots/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.PolicyHeaders.XssProtection/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.RequestTracing/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.ResponseCache/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.ResponseCompression/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Session/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.StartupTasks/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.StaticFiles/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Storage.Azure/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Storage.Local/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.TimeZone/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.Versioning/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api.VirusScan/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Api._Blank/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Console.CustomConfigSection/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Console.CustomService/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Console.Data.InMemory/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Console.Data.MySql/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Console.Data.PostgreSQL/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Console.Data.SqLite/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Console.Data.SqlServer/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Console.Eventing.RabbitMq/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Console.ExceptionHandling/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Console.Localization/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Console.Logging.Log4Net/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Console.Logging.Microsoft/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Console.Logging.NLog/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Console.Logging.Serilog/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Console.StartupTasks/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Console.Storage.Azure/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Console.Storage.Local/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Console.Workers/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Console._Blank/AGENTS.md | 24 +++++++++++- .../skills/nano-add-custom-endpoint/SKILL.md | 39 ++++++++++++------- .../skills/nano-add-data-provider/SKILL.md | 4 +- .../nano-add-postman-collection/SKILL.md | 8 ++-- .../nano-add-custom-endpoint.prompt.md | 39 ++++++++++++------- .../prompts/nano-add-data-provider.prompt.md | 4 +- .../nano-add-postman-collection.prompt.md | 8 ++-- Web._Blank/AGENTS.md | 24 +++++++++++- 644 files changed, 7728 insertions(+), 3864 deletions(-) diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-data-provider/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-data-provider.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-postman-collection.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.ApiClients.Audit/AGENTS.md b/Api.ApiClients.Audit/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.ApiClients.Audit/AGENTS.md +++ b/Api.ApiClients.Audit/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-data-provider/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-data-provider.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-postman-collection.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.ApiClients.Entity/AGENTS.md b/Api.ApiClients.Entity/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.ApiClients.Entity/AGENTS.md +++ b/Api.ApiClients.Entity/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-data-provider/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-data-provider.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-postman-collection.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.ApiClients.RootLogIn/AGENTS.md b/Api.ApiClients.RootLogIn/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.ApiClients.RootLogIn/AGENTS.md +++ b/Api.ApiClients.RootLogIn/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.ApiClients/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.ApiClients/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.ApiClients/.claude/skills/nano-add-data-provider/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.ApiClients/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ApiClients/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.ApiClients/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.ApiClients/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.ApiClients/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.ApiClients/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.ApiClients/.github/prompts/nano-add-data-provider.prompt.md b/Api.ApiClients/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.ApiClients/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ApiClients/.github/prompts/nano-add-postman-collection.prompt.md b/Api.ApiClients/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.ApiClients/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.ApiClients/AGENTS.md b/Api.ApiClients/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.ApiClients/AGENTS.md +++ b/Api.ApiClients/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-data-provider.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Auth.External.Custom/AGENTS.md b/Api.Auth.External.Custom/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Auth.External.Custom/AGENTS.md +++ b/Api.Auth.External.Custom/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Auth.External.Microsoft/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Auth.External.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Auth.External.Microsoft/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Auth.External.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-data-provider.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Auth.External.Microsoft/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Auth.External.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Auth.External.Microsoft/AGENTS.md b/Api.Auth.External.Microsoft/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Auth.External.Microsoft/AGENTS.md +++ b/Api.Auth.External.Microsoft/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Auth.RootLogin/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Auth.RootLogin/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Auth.RootLogin/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Auth.RootLogin/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Auth.RootLogin/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Auth.RootLogin/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Auth.RootLogin/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Auth.RootLogin/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Auth.RootLogin/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Auth.RootLogin/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Auth.RootLogin/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Auth.RootLogin/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Auth.RootLogin/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Auth.RootLogin/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Auth.RootLogin/.github/prompts/nano-add-data-provider.prompt.md b/Api.Auth.RootLogin/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Auth.RootLogin/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Auth.RootLogin/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Auth.RootLogin/AGENTS.md b/Api.Auth.RootLogin/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Auth.RootLogin/AGENTS.md +++ b/Api.Auth.RootLogin/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Authorization/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Authorization/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Authorization/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Authorization/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Authorization/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Authorization/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Authorization/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Authorization/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Authorization/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Authorization/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Authorization/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Authorization/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Authorization/.github/prompts/nano-add-data-provider.prompt.md b/Api.Authorization/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Authorization/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Authorization/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Authorization/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Authorization/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Authorization/AGENTS.md b/Api.Authorization/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Authorization/AGENTS.md +++ b/Api.Authorization/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.ContentNegotiation/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.ContentNegotiation/.claude/skills/nano-add-data-provider/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ContentNegotiation/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-data-provider.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-postman-collection.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.ContentNegotiation/AGENTS.md b/Api.ContentNegotiation/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.ContentNegotiation/AGENTS.md +++ b/Api.ContentNegotiation/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Cookies/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Cookies/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Cookies/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Cookies/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Cookies/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Cookies/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Cookies/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Cookies/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Cookies/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Cookies/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Cookies/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Cookies/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Cookies/.github/prompts/nano-add-data-provider.prompt.md b/Api.Cookies/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Cookies/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Cookies/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Cookies/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Cookies/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Cookies/AGENTS.md b/Api.Cookies/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Cookies/AGENTS.md +++ b/Api.Cookies/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.CustomConfigSection/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.CustomConfigSection/.claude/skills/nano-add-data-provider/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-data-provider.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.CustomConfigSection/AGENTS.md b/Api.CustomConfigSection/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.CustomConfigSection/AGENTS.md +++ b/Api.CustomConfigSection/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.CustomMiddleware/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.CustomMiddleware/.claude/skills/nano-add-data-provider/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.CustomMiddleware/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-data-provider.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-postman-collection.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.CustomMiddleware/AGENTS.md b/Api.CustomMiddleware/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.CustomMiddleware/AGENTS.md +++ b/Api.CustomMiddleware/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.CustomService/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.CustomService/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.CustomService/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.CustomService/.claude/skills/nano-add-data-provider/SKILL.md b/Api.CustomService/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.CustomService/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.CustomService/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.CustomService/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.CustomService/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.CustomService/.github/prompts/nano-add-data-provider.prompt.md b/Api.CustomService/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.CustomService/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.CustomService/.github/prompts/nano-add-postman-collection.prompt.md b/Api.CustomService/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.CustomService/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.CustomService/AGENTS.md b/Api.CustomService/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.CustomService/AGENTS.md +++ b/Api.CustomService/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Data.Audit/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Data.Audit/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Data.Audit/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Data.Audit/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.Audit/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.Audit/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Data.Audit/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.Audit/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Audit/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Data.Audit/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Data.Audit/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Data.Audit/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Data.Audit/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.Audit/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Data.Audit/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.Audit/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.Audit/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Data.Audit/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Audit/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.Audit/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Data.Audit/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Data.Audit/AGENTS.md b/Api.Data.Audit/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Data.Audit/AGENTS.md +++ b/Api.Data.Audit/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Data.EntityEvents/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Data.EntityEvents/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Data.EntityEvents/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Data.EntityEvents/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.EntityEvents/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.EntityEvents/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Data.EntityEvents/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.EntityEvents/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.EntityEvents/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Data.EntityEvents/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Data.EntityEvents/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Data.EntityEvents/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Data.EntityEvents/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.EntityEvents/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.EntityEvents/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.EntityEvents/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.EntityEvents/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.EntityEvents/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Data.EntityEvents/AGENTS.md b/Api.Data.EntityEvents/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Data.EntityEvents/AGENTS.md +++ b/Api.Data.EntityEvents/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Data.Identity.Auth.ApiKey/AGENTS.md b/Api.Data.Identity.Auth.ApiKey/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Data.Identity.Auth.ApiKey/AGENTS.md +++ b/Api.Data.Identity.Auth.ApiKey/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Data.Identity.Auth.External.Custom/AGENTS.md b/Api.Data.Identity.Auth.External.Custom/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Data.Identity.Auth.External.Custom/AGENTS.md +++ b/Api.Data.Identity.Auth.External.Custom/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Data.Identity.Auth.Jwt/AGENTS.md b/Api.Data.Identity.Auth.Jwt/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Data.Identity.Auth.Jwt/AGENTS.md +++ b/Api.Data.Identity.Auth.Jwt/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Data.Identity/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Data.Identity/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Data.Identity/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Data.Identity/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.Identity/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.Identity/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Data.Identity/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.Identity/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Identity/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Data.Identity/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Data.Identity/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Data.Identity/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Data.Identity/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.Identity/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Data.Identity/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.Identity/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.Identity/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Data.Identity/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Identity/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.Identity/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Data.Identity/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Data.Identity/AGENTS.md b/Api.Data.Identity/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Data.Identity/AGENTS.md +++ b/Api.Data.Identity/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Data.InMemory/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Data.InMemory/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Data.InMemory/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Data.InMemory/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.InMemory/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.InMemory/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Data.InMemory/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.InMemory/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.InMemory/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Data.InMemory/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Data.InMemory/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Data.InMemory/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Data.InMemory/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.InMemory/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Data.InMemory/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.InMemory/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.InMemory/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Data.InMemory/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.InMemory/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.InMemory/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Data.InMemory/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Data.InMemory/AGENTS.md b/Api.Data.InMemory/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Data.InMemory/AGENTS.md +++ b/Api.Data.InMemory/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Data.LazyLoading/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Data.LazyLoading/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Data.LazyLoading/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Data.LazyLoading/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.LazyLoading/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.LazyLoading/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Data.LazyLoading/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.LazyLoading/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.LazyLoading/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Data.LazyLoading/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Data.LazyLoading/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Data.LazyLoading/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Data.LazyLoading/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.LazyLoading/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.LazyLoading/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.LazyLoading/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.LazyLoading/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.LazyLoading/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Data.LazyLoading/AGENTS.md b/Api.Data.LazyLoading/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Data.LazyLoading/AGENTS.md +++ b/Api.Data.LazyLoading/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Data.MySql.Collation/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Data.MySql.Collation/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Data.MySql.Collation/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Data.MySql.Collation/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.MySql.Collation/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.MySql.Collation/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Data.MySql.Collation/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.MySql.Collation/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.MySql.Collation/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Data.MySql.Collation/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Data.MySql.Collation/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Data.MySql.Collation/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Data.MySql.Collation/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.MySql.Collation/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Data.MySql.Collation/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.MySql.Collation/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.MySql.Collation/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.MySql.Collation/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Data.MySql.Collation/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.MySql.Collation/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.MySql.Collation/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.MySql.Collation/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Data.MySql.Collation/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.MySql.Collation/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Data.MySql.Collation/AGENTS.md b/Api.Data.MySql.Collation/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Data.MySql.Collation/AGENTS.md +++ b/Api.Data.MySql.Collation/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Data.MySql.Mappings/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Data.MySql.Mappings/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Data.MySql.Mappings/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Data.MySql.Mappings/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.MySql.Mappings/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.MySql.Mappings/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Data.MySql.Mappings/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.MySql.Mappings/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.MySql.Mappings/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Data.MySql.Mappings/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Data.MySql.Mappings/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Data.MySql.Mappings/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Data.MySql.Mappings/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.MySql.Mappings/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Data.MySql.Mappings/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.MySql.Mappings/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.MySql.Mappings/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Data.MySql.Mappings/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.MySql.Mappings/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.MySql.Mappings/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Data.MySql.Mappings/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Data.MySql.Mappings/AGENTS.md b/Api.Data.MySql.Mappings/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Data.MySql.Mappings/AGENTS.md +++ b/Api.Data.MySql.Mappings/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Data.MySql.Spatial/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Data.MySql.Spatial/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Data.MySql.Spatial/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Data.MySql.Spatial/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.MySql.Spatial/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.MySql.Spatial/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Data.MySql.Spatial/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.MySql.Spatial/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.MySql.Spatial/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Data.MySql.Spatial/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Data.MySql.Spatial/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Data.MySql.Spatial/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Data.MySql.Spatial/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.MySql.Spatial/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Data.MySql.Spatial/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.MySql.Spatial/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.MySql.Spatial/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Data.MySql.Spatial/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.MySql.Spatial/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.MySql.Spatial/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Data.MySql.Spatial/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Data.MySql.Spatial/AGENTS.md b/Api.Data.MySql.Spatial/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Data.MySql.Spatial/AGENTS.md +++ b/Api.Data.MySql.Spatial/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Data.MySql.StoredProcedures/AGENTS.md b/Api.Data.MySql.StoredProcedures/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Data.MySql.StoredProcedures/AGENTS.md +++ b/Api.Data.MySql.StoredProcedures/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Data.MySql.Views/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Data.MySql.Views/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Data.MySql.Views/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Data.MySql.Views/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.MySql.Views/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.MySql.Views/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Data.MySql.Views/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.MySql.Views/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.MySql.Views/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Data.MySql.Views/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Data.MySql.Views/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Data.MySql.Views/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Data.MySql.Views/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.MySql.Views/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Data.MySql.Views/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.MySql.Views/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.MySql.Views/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.MySql.Views/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Data.MySql.Views/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.MySql.Views/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.MySql.Views/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.MySql.Views/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Data.MySql.Views/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.MySql.Views/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Data.MySql.Views/AGENTS.md b/Api.Data.MySql.Views/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Data.MySql.Views/AGENTS.md +++ b/Api.Data.MySql.Views/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Data.MySql/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Data.MySql/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Data.MySql/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Data.MySql/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.MySql/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.MySql/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Data.MySql/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.MySql/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.MySql/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Data.MySql/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Data.MySql/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Data.MySql/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Data.MySql/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.MySql/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Data.MySql/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.MySql/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.MySql/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Data.MySql/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.MySql/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.MySql/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Data.MySql/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Data.MySql/AGENTS.md b/Api.Data.MySql/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Data.MySql/AGENTS.md +++ b/Api.Data.MySql/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Data.PostgreSQL.Spatial/AGENTS.md b/Api.Data.PostgreSQL.Spatial/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Data.PostgreSQL.Spatial/AGENTS.md +++ b/Api.Data.PostgreSQL.Spatial/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Data.PostgreSQL/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Data.PostgreSQL/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Data.PostgreSQL/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Data.PostgreSQL/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.PostgreSQL/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.PostgreSQL/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Data.PostgreSQL/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.PostgreSQL/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.PostgreSQL/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Data.PostgreSQL/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Data.PostgreSQL/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Data.PostgreSQL/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Data.PostgreSQL/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.PostgreSQL/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.PostgreSQL/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.PostgreSQL/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.PostgreSQL/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.PostgreSQL/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Data.PostgreSQL/AGENTS.md b/Api.Data.PostgreSQL/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Data.PostgreSQL/AGENTS.md +++ b/Api.Data.PostgreSQL/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Data.Repository.AutoSave/AGENTS.md b/Api.Data.Repository.AutoSave/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Data.Repository.AutoSave/AGENTS.md +++ b/Api.Data.Repository.AutoSave/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Data.Repository.Includes/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Data.Repository.Includes/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Data.Repository.Includes/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Data.Repository.Includes/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.Repository.Includes/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.Repository.Includes/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Data.Repository.Includes/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.Repository.Includes/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Repository.Includes/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Data.Repository.Includes/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Data.Repository.Includes/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Data.Repository.Includes/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Data.Repository.Includes/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.Repository.Includes/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Data.Repository.Includes/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.Repository.Includes/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.Repository.Includes/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.Repository.Includes/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Data.Repository.Includes/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.Repository.Includes/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Repository.Includes/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.Repository.Includes/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Data.Repository.Includes/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.Repository.Includes/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Data.Repository.Includes/AGENTS.md b/Api.Data.Repository.Includes/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Data.Repository.Includes/AGENTS.md +++ b/Api.Data.Repository.Includes/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Data.SoftDelete/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Data.SoftDelete/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Data.SoftDelete/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Data.SoftDelete/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.SoftDelete/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.SoftDelete/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Data.SoftDelete/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.SoftDelete/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.SoftDelete/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Data.SoftDelete/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Data.SoftDelete/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Data.SoftDelete/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Data.SoftDelete/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.SoftDelete/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.SoftDelete/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.SoftDelete/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.SoftDelete/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.SoftDelete/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Data.SoftDelete/AGENTS.md b/Api.Data.SoftDelete/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Data.SoftDelete/AGENTS.md +++ b/Api.Data.SoftDelete/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Data.SqLite/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Data.SqLite/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Data.SqLite/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Data.SqLite/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.SqLite/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.SqLite/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Data.SqLite/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.SqLite/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.SqLite/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Data.SqLite/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Data.SqLite/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Data.SqLite/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Data.SqLite/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.SqLite/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Data.SqLite/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.SqLite/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.SqLite/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Data.SqLite/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.SqLite/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.SqLite/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Data.SqLite/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Data.SqLite/AGENTS.md b/Api.Data.SqLite/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Data.SqLite/AGENTS.md +++ b/Api.Data.SqLite/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Data.SqlServer.Spatial/AGENTS.md b/Api.Data.SqlServer.Spatial/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Data.SqlServer.Spatial/AGENTS.md +++ b/Api.Data.SqlServer.Spatial/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Data.SqlServer/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Data.SqlServer/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Data.SqlServer/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Data.SqlServer/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.SqlServer/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.SqlServer/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Data.SqlServer/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.SqlServer/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.SqlServer/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Data.SqlServer/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Data.SqlServer/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Data.SqlServer/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Data.SqlServer/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.SqlServer/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.SqlServer/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.SqlServer/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.SqlServer/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.SqlServer/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Data.SqlServer/AGENTS.md b/Api.Data.SqlServer/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Data.SqlServer/AGENTS.md +++ b/Api.Data.SqlServer/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Data.Triggers/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Data.Triggers/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Data.Triggers/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Data.Triggers/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.Triggers/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.Triggers/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Data.Triggers/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.Triggers/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Triggers/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Data.Triggers/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Data.Triggers/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Data.Triggers/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Data.Triggers/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.Triggers/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Data.Triggers/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Data.Triggers/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.Triggers/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Data.Triggers/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Triggers/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.Triggers/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Data.Triggers/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Data.Triggers/AGENTS.md b/Api.Data.Triggers/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Data.Triggers/AGENTS.md +++ b/Api.Data.Triggers/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Documentation.Csp/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Documentation.Csp/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Documentation.Csp/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Documentation.Csp/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Documentation.Csp/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Documentation.Csp/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Documentation.Csp/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Documentation.Csp/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Documentation.Csp/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Documentation.Csp/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Documentation.Csp/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Documentation.Csp/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Documentation.Csp/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Documentation.Csp/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Documentation.Csp/.github/prompts/nano-add-data-provider.prompt.md b/Api.Documentation.Csp/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Documentation.Csp/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Documentation.Csp/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Documentation.Csp/AGENTS.md b/Api.Documentation.Csp/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Documentation.Csp/AGENTS.md +++ b/Api.Documentation.Csp/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Documentation/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Documentation/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Documentation/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Documentation/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Documentation/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Documentation/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Documentation/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Documentation/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Documentation/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Documentation/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Documentation/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Documentation/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Documentation/.github/prompts/nano-add-data-provider.prompt.md b/Api.Documentation/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Documentation/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Documentation/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Documentation/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Documentation/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Documentation/AGENTS.md b/Api.Documentation/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Documentation/AGENTS.md +++ b/Api.Documentation/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.ErrorHandling/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.ErrorHandling/.claude/skills/nano-add-data-provider/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ErrorHandling/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.ErrorHandling/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.ErrorHandling/.github/prompts/nano-add-data-provider.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ErrorHandling/.github/prompts/nano-add-postman-collection.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.ErrorHandling/AGENTS.md b/Api.ErrorHandling/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.ErrorHandling/AGENTS.md +++ b/Api.ErrorHandling/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Eventing.RabbitMq/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Eventing.RabbitMq/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Eventing.RabbitMq/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Eventing.RabbitMq/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Eventing.RabbitMq/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Eventing.RabbitMq/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Eventing.RabbitMq/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Eventing.RabbitMq/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Eventing.RabbitMq/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Eventing.RabbitMq/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Eventing.RabbitMq/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Eventing.RabbitMq/.github/prompts/nano-add-data-provider.prompt.md b/Api.Eventing.RabbitMq/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Eventing.RabbitMq/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Eventing.RabbitMq/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Eventing.RabbitMq/AGENTS.md b/Api.Eventing.RabbitMq/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Eventing.RabbitMq/AGENTS.md +++ b/Api.Eventing.RabbitMq/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.HealthChecks/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.HealthChecks/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.HealthChecks/.claude/skills/nano-add-data-provider/SKILL.md b/Api.HealthChecks/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.HealthChecks/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.HealthChecks/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.HealthChecks/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.HealthChecks/.github/prompts/nano-add-data-provider.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.HealthChecks/.github/prompts/nano-add-postman-collection.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.HealthChecks/AGENTS.md b/Api.HealthChecks/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.HealthChecks/AGENTS.md +++ b/Api.HealthChecks/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Hosting.Http/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Hosting.Http/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Hosting.Http/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Hosting.Http/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Hosting.Http/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Hosting.Http/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Hosting.Http/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Hosting.Http/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Hosting.Http/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Hosting.Http/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Hosting.Http/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Hosting.Http/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Hosting.Http/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Hosting.Http/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Hosting.Http/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Hosting.Http/.github/prompts/nano-add-data-provider.prompt.md b/Api.Hosting.Http/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Hosting.Http/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Hosting.Http/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Hosting.Http/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Hosting.Http/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Hosting.Http/AGENTS.md b/Api.Hosting.Http/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Hosting.Http/AGENTS.md +++ b/Api.Hosting.Http/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Hosting.Https/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Hosting.Https/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Hosting.Https/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Hosting.Https/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Hosting.Https/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Hosting.Https/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Hosting.Https/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Hosting.Https/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Hosting.Https/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Hosting.Https/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Hosting.Https/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Hosting.Https/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Hosting.Https/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Hosting.Https/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Hosting.Https/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Hosting.Https/.github/prompts/nano-add-data-provider.prompt.md b/Api.Hosting.Https/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Hosting.Https/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Hosting.Https/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Hosting.Https/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Hosting.Https/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Hosting.Https/AGENTS.md b/Api.Hosting.Https/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Hosting.Https/AGENTS.md +++ b/Api.Hosting.Https/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-data-provider.prompt.md b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Hosting.MultipartLimits/AGENTS.md b/Api.Hosting.MultipartLimits/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Hosting.MultipartLimits/AGENTS.md +++ b/Api.Hosting.MultipartLimits/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Localization/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Localization/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Localization/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Localization/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Localization/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Localization/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Localization/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Localization/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Localization/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Localization/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Localization/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Localization/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Localization/.github/prompts/nano-add-data-provider.prompt.md b/Api.Localization/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Localization/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Localization/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Localization/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Localization/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Localization/AGENTS.md b/Api.Localization/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Localization/AGENTS.md +++ b/Api.Localization/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Logging.Log4Net/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Logging.Log4Net/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Logging.Log4Net/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Logging.Log4Net/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Logging.Log4Net/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Logging.Log4Net/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Logging.Log4Net/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Logging.Log4Net/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Logging.Log4Net/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Logging.Log4Net/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Logging.Log4Net/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Logging.Log4Net/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Logging.Log4Net/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Logging.Log4Net/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Logging.Log4Net/.github/prompts/nano-add-data-provider.prompt.md b/Api.Logging.Log4Net/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Logging.Log4Net/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Logging.Log4Net/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Logging.Log4Net/AGENTS.md b/Api.Logging.Log4Net/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Logging.Log4Net/AGENTS.md +++ b/Api.Logging.Log4Net/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Logging.Microsoft/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Logging.Microsoft/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Logging.Microsoft/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Logging.Microsoft/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Logging.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Logging.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Logging.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Logging.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Logging.Microsoft/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Logging.Microsoft/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Logging.Microsoft/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Logging.Microsoft/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Logging.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Logging.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Logging.Microsoft/.github/prompts/nano-add-data-provider.prompt.md b/Api.Logging.Microsoft/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Logging.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Logging.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Logging.Microsoft/AGENTS.md b/Api.Logging.Microsoft/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Logging.Microsoft/AGENTS.md +++ b/Api.Logging.Microsoft/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Logging.NLog/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Logging.NLog/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Logging.NLog/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Logging.NLog/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Logging.NLog/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Logging.NLog/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Logging.NLog/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Logging.NLog/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Logging.NLog/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Logging.NLog/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Logging.NLog/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Logging.NLog/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Logging.NLog/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Logging.NLog/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Logging.NLog/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Logging.NLog/.github/prompts/nano-add-data-provider.prompt.md b/Api.Logging.NLog/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Logging.NLog/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Logging.NLog/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Logging.NLog/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Logging.NLog/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Logging.NLog/AGENTS.md b/Api.Logging.NLog/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Logging.NLog/AGENTS.md +++ b/Api.Logging.NLog/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Logging.Serilog/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Logging.Serilog/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Logging.Serilog/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Logging.Serilog/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Logging.Serilog/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Logging.Serilog/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Logging.Serilog/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Logging.Serilog/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Logging.Serilog/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Logging.Serilog/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Logging.Serilog/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Logging.Serilog/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Logging.Serilog/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Logging.Serilog/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Logging.Serilog/.github/prompts/nano-add-data-provider.prompt.md b/Api.Logging.Serilog/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Logging.Serilog/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Logging.Serilog/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Logging.Serilog/AGENTS.md b/Api.Logging.Serilog/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Logging.Serilog/AGENTS.md +++ b/Api.Logging.Serilog/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Metrics/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Metrics/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Metrics/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Metrics/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Metrics/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Metrics/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Metrics/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Metrics/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Metrics/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Metrics/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Metrics/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Metrics/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Metrics/.github/prompts/nano-add-data-provider.prompt.md b/Api.Metrics/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Metrics/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Metrics/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Metrics/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Metrics/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Metrics/AGENTS.md b/Api.Metrics/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Metrics/AGENTS.md +++ b/Api.Metrics/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.MultipartJson/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.MultipartJson/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.MultipartJson/.claude/skills/nano-add-data-provider/SKILL.md b/Api.MultipartJson/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.MultipartJson/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.MultipartJson/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.MultipartJson/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.MultipartJson/.github/prompts/nano-add-data-provider.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.MultipartJson/.github/prompts/nano-add-postman-collection.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.MultipartJson/AGENTS.md b/Api.MultipartJson/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.MultipartJson/AGENTS.md +++ b/Api.MultipartJson/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-data-provider/SKILL.md b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-data-provider.prompt.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-postman-collection.prompt.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-data-provider/SKILL.md b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-data-provider.prompt.md b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-postman-collection.prompt.md b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md +++ b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-data-provider/SKILL.md b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-data-provider.prompt.md b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-postman-collection.prompt.md b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.PolicyHeaders.Cors/AGENTS.md b/Api.PolicyHeaders.Cors/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.PolicyHeaders.Cors/AGENTS.md +++ b/Api.PolicyHeaders.Cors/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-data-provider/SKILL.md b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-data-provider.prompt.md b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-postman-collection.prompt.md b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md +++ b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-data-provider/SKILL.md b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-data-provider.prompt.md b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-postman-collection.prompt.md b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.PolicyHeaders.FrameOptions/AGENTS.md b/Api.PolicyHeaders.FrameOptions/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.PolicyHeaders.FrameOptions/AGENTS.md +++ b/Api.PolicyHeaders.FrameOptions/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-data-provider/SKILL.md b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-data-provider.prompt.md b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-postman-collection.prompt.md b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.PolicyHeaders.Hsts/AGENTS.md b/Api.PolicyHeaders.Hsts/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.PolicyHeaders.Hsts/AGENTS.md +++ b/Api.PolicyHeaders.Hsts/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-data-provider/SKILL.md b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-data-provider.prompt.md b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-postman-collection.prompt.md b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-data-provider/SKILL.md b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-data-provider.prompt.md b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-postman-collection.prompt.md b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.PolicyHeaders.Robots/AGENTS.md b/Api.PolicyHeaders.Robots/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.PolicyHeaders.Robots/AGENTS.md +++ b/Api.PolicyHeaders.Robots/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-data-provider/SKILL.md b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-data-provider.prompt.md b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-postman-collection.prompt.md b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.PolicyHeaders.XssProtection/AGENTS.md b/Api.PolicyHeaders.XssProtection/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.PolicyHeaders.XssProtection/AGENTS.md +++ b/Api.PolicyHeaders.XssProtection/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.RequestTracing/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.RequestTracing/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.RequestTracing/.claude/skills/nano-add-data-provider/SKILL.md b/Api.RequestTracing/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.RequestTracing/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.RequestTracing/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.RequestTracing/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.RequestTracing/.github/prompts/nano-add-data-provider.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.RequestTracing/.github/prompts/nano-add-postman-collection.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.RequestTracing/AGENTS.md b/Api.RequestTracing/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.RequestTracing/AGENTS.md +++ b/Api.RequestTracing/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.ResponseCache/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.ResponseCache/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.ResponseCache/.claude/skills/nano-add-data-provider/SKILL.md b/Api.ResponseCache/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ResponseCache/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.ResponseCache/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.ResponseCache/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.ResponseCache/.github/prompts/nano-add-data-provider.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ResponseCache/.github/prompts/nano-add-postman-collection.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.ResponseCache/AGENTS.md b/Api.ResponseCache/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.ResponseCache/AGENTS.md +++ b/Api.ResponseCache/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.ResponseCompression/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.ResponseCompression/.claude/skills/nano-add-data-provider/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ResponseCompression/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.ResponseCompression/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.ResponseCompression/.github/prompts/nano-add-data-provider.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ResponseCompression/.github/prompts/nano-add-postman-collection.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.ResponseCompression/AGENTS.md b/Api.ResponseCompression/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.ResponseCompression/AGENTS.md +++ b/Api.ResponseCompression/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Session/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Session/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Session/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Session/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Session/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Session/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Session/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Session/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Session/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Session/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Session/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Session/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Session/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Session/.github/prompts/nano-add-data-provider.prompt.md b/Api.Session/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Session/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Session/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Session/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Session/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Session/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Session/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Session/AGENTS.md b/Api.Session/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Session/AGENTS.md +++ b/Api.Session/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.StartupTasks/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.StartupTasks/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.StartupTasks/.claude/skills/nano-add-data-provider/SKILL.md b/Api.StartupTasks/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.StartupTasks/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.StartupTasks/.github/prompts/nano-add-data-provider.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.StartupTasks/AGENTS.md b/Api.StartupTasks/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.StartupTasks/AGENTS.md +++ b/Api.StartupTasks/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.StaticFiles/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.StaticFiles/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.StaticFiles/.claude/skills/nano-add-data-provider/SKILL.md b/Api.StaticFiles/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.StaticFiles/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.StaticFiles/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.StaticFiles/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.StaticFiles/.github/prompts/nano-add-data-provider.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.StaticFiles/.github/prompts/nano-add-postman-collection.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.StaticFiles/AGENTS.md b/Api.StaticFiles/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.StaticFiles/AGENTS.md +++ b/Api.StaticFiles/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Storage.Azure/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Storage.Azure/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Storage.Azure/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Storage.Azure/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Storage.Azure/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Storage.Azure/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Storage.Azure/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Storage.Azure/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Storage.Azure/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Storage.Azure/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Storage.Azure/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Storage.Azure/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Storage.Azure/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Storage.Azure/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Storage.Azure/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Storage.Azure/.github/prompts/nano-add-data-provider.prompt.md b/Api.Storage.Azure/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Storage.Azure/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Storage.Azure/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Storage.Azure/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Storage.Azure/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Storage.Azure/AGENTS.md b/Api.Storage.Azure/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Storage.Azure/AGENTS.md +++ b/Api.Storage.Azure/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Storage.Local/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Storage.Local/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Storage.Local/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Storage.Local/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Storage.Local/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Storage.Local/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Storage.Local/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Storage.Local/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Storage.Local/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Storage.Local/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Storage.Local/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Storage.Local/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Storage.Local/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Storage.Local/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Storage.Local/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Storage.Local/.github/prompts/nano-add-data-provider.prompt.md b/Api.Storage.Local/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Storage.Local/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Storage.Local/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Storage.Local/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Storage.Local/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Storage.Local/AGENTS.md b/Api.Storage.Local/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Storage.Local/AGENTS.md +++ b/Api.Storage.Local/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.TimeZone/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.TimeZone/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.TimeZone/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.TimeZone/.claude/skills/nano-add-data-provider/SKILL.md b/Api.TimeZone/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.TimeZone/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.TimeZone/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.TimeZone/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.TimeZone/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.TimeZone/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.TimeZone/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.TimeZone/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.TimeZone/.github/prompts/nano-add-data-provider.prompt.md b/Api.TimeZone/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.TimeZone/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.TimeZone/.github/prompts/nano-add-postman-collection.prompt.md b/Api.TimeZone/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.TimeZone/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.TimeZone/AGENTS.md b/Api.TimeZone/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.TimeZone/AGENTS.md +++ b/Api.TimeZone/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.Versioning/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.Versioning/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.Versioning/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Versioning/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Versioning/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.Versioning/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Versioning/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.Versioning/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.Versioning/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.Versioning/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Versioning/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.Versioning/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.Versioning/.github/prompts/nano-add-data-provider.prompt.md b/Api.Versioning/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.Versioning/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Versioning/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Versioning/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.Versioning/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.Versioning/AGENTS.md b/Api.Versioning/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.Versioning/AGENTS.md +++ b/Api.Versioning/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api.VirusScan/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api.VirusScan/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api.VirusScan/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.VirusScan/.claude/skills/nano-add-data-provider/SKILL.md b/Api.VirusScan/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api.VirusScan/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.VirusScan/.claude/skills/nano-add-postman-collection/SKILL.md b/Api.VirusScan/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api.VirusScan/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api.VirusScan/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.VirusScan/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api.VirusScan/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api.VirusScan/.github/prompts/nano-add-data-provider.prompt.md b/Api.VirusScan/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api.VirusScan/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.VirusScan/.github/prompts/nano-add-postman-collection.prompt.md b/Api.VirusScan/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api.VirusScan/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api.VirusScan/AGENTS.md b/Api.VirusScan/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api.VirusScan/AGENTS.md +++ b/Api.VirusScan/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Api._Blank/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Api._Blank/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Api._Blank/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api._Blank/.claude/skills/nano-add-data-provider/SKILL.md b/Api._Blank/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Api._Blank/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api._Blank/.claude/skills/nano-add-postman-collection/SKILL.md b/Api._Blank/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Api._Blank/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Api._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Api._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Api._Blank/.github/prompts/nano-add-data-provider.prompt.md b/Api._Blank/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Api._Blank/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api._Blank/.github/prompts/nano-add-postman-collection.prompt.md b/Api._Blank/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Api._Blank/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Api._Blank/AGENTS.md b/Api._Blank/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Api._Blank/AGENTS.md +++ b/Api._Blank/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Console.CustomConfigSection/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.CustomConfigSection/.claude/skills/nano-add-data-provider/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-data-provider.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Console.CustomConfigSection/AGENTS.md b/Console.CustomConfigSection/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Console.CustomConfigSection/AGENTS.md +++ b/Console.CustomConfigSection/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Console.CustomService/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Console.CustomService/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Console.CustomService/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.CustomService/.claude/skills/nano-add-data-provider/SKILL.md b/Console.CustomService/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Console.CustomService/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md b/Console.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Console.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Console.CustomService/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.CustomService/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Console.CustomService/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.CustomService/.github/prompts/nano-add-data-provider.prompt.md b/Console.CustomService/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Console.CustomService/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.CustomService/.github/prompts/nano-add-postman-collection.prompt.md b/Console.CustomService/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Console.CustomService/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Console.CustomService/AGENTS.md b/Console.CustomService/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Console.CustomService/AGENTS.md +++ b/Console.CustomService/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Console.Data.InMemory/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Console.Data.InMemory/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Console.Data.InMemory/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.Data.InMemory/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Data.InMemory/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Data.InMemory/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Console.Data.InMemory/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Data.InMemory/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Data.InMemory/.claude/skills/nano-add-postman-collection/SKILL.md b/Console.Data.InMemory/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Console.Data.InMemory/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.Data.InMemory/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Console.Data.InMemory/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Data.InMemory/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Console.Data.InMemory/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Data.InMemory/.github/prompts/nano-add-data-provider.prompt.md b/Console.Data.InMemory/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Console.Data.InMemory/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Data.InMemory/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Data.InMemory/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Console.Data.InMemory/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Console.Data.InMemory/AGENTS.md b/Console.Data.InMemory/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Console.Data.InMemory/AGENTS.md +++ b/Console.Data.InMemory/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Console.Data.MySql/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Console.Data.MySql/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Console.Data.MySql/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.Data.MySql/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Data.MySql/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Data.MySql/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Console.Data.MySql/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Data.MySql/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Data.MySql/.claude/skills/nano-add-postman-collection/SKILL.md b/Console.Data.MySql/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Console.Data.MySql/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.Data.MySql/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Console.Data.MySql/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Data.MySql/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Console.Data.MySql/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Data.MySql/.github/prompts/nano-add-data-provider.prompt.md b/Console.Data.MySql/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Console.Data.MySql/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Data.MySql/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Data.MySql/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Console.Data.MySql/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Console.Data.MySql/AGENTS.md b/Console.Data.MySql/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Console.Data.MySql/AGENTS.md +++ b/Console.Data.MySql/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Console.Data.PostgreSQL/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Console.Data.PostgreSQL/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Console.Data.PostgreSQL/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.Data.PostgreSQL/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Data.PostgreSQL/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Data.PostgreSQL/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Console.Data.PostgreSQL/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Data.PostgreSQL/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Data.PostgreSQL/.claude/skills/nano-add-postman-collection/SKILL.md b/Console.Data.PostgreSQL/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Console.Data.PostgreSQL/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.Data.PostgreSQL/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Console.Data.PostgreSQL/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Data.PostgreSQL/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Data.PostgreSQL/.github/prompts/nano-add-data-provider.prompt.md b/Console.Data.PostgreSQL/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Data.PostgreSQL/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Data.PostgreSQL/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Console.Data.PostgreSQL/AGENTS.md b/Console.Data.PostgreSQL/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Console.Data.PostgreSQL/AGENTS.md +++ b/Console.Data.PostgreSQL/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Console.Data.SqLite/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Console.Data.SqLite/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Console.Data.SqLite/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.Data.SqLite/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Data.SqLite/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Data.SqLite/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Console.Data.SqLite/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Data.SqLite/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Data.SqLite/.claude/skills/nano-add-postman-collection/SKILL.md b/Console.Data.SqLite/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Console.Data.SqLite/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.Data.SqLite/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Console.Data.SqLite/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Data.SqLite/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Console.Data.SqLite/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Data.SqLite/.github/prompts/nano-add-data-provider.prompt.md b/Console.Data.SqLite/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Console.Data.SqLite/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Data.SqLite/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Data.SqLite/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Console.Data.SqLite/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Console.Data.SqLite/AGENTS.md b/Console.Data.SqLite/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Console.Data.SqLite/AGENTS.md +++ b/Console.Data.SqLite/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Console.Data.SqlServer/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Console.Data.SqlServer/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Console.Data.SqlServer/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.Data.SqlServer/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Data.SqlServer/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Data.SqlServer/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Console.Data.SqlServer/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Data.SqlServer/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Data.SqlServer/.claude/skills/nano-add-postman-collection/SKILL.md b/Console.Data.SqlServer/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Console.Data.SqlServer/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.Data.SqlServer/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Console.Data.SqlServer/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Data.SqlServer/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Data.SqlServer/.github/prompts/nano-add-data-provider.prompt.md b/Console.Data.SqlServer/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Data.SqlServer/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Data.SqlServer/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Console.Data.SqlServer/AGENTS.md b/Console.Data.SqlServer/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Console.Data.SqlServer/AGENTS.md +++ b/Console.Data.SqlServer/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Console.Eventing.RabbitMq/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Console.Eventing.RabbitMq/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Console.Eventing.RabbitMq/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Eventing.RabbitMq/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Eventing.RabbitMq/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Console.Eventing.RabbitMq/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Eventing.RabbitMq/.claude/skills/nano-add-postman-collection/SKILL.md b/Console.Eventing.RabbitMq/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Console.Eventing.RabbitMq/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Console.Eventing.RabbitMq/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Eventing.RabbitMq/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Eventing.RabbitMq/.github/prompts/nano-add-data-provider.prompt.md b/Console.Eventing.RabbitMq/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Eventing.RabbitMq/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Eventing.RabbitMq/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Console.Eventing.RabbitMq/AGENTS.md b/Console.Eventing.RabbitMq/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Console.Eventing.RabbitMq/AGENTS.md +++ b/Console.Eventing.RabbitMq/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Console.ExceptionHandling/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.ExceptionHandling/.claude/skills/nano-add-data-provider/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.ExceptionHandling/.claude/skills/nano-add-postman-collection/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-data-provider.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-postman-collection.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Console.ExceptionHandling/AGENTS.md b/Console.ExceptionHandling/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Console.ExceptionHandling/AGENTS.md +++ b/Console.ExceptionHandling/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Console.Localization/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Console.Localization/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Console.Localization/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Localization/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Localization/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Console.Localization/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Localization/.claude/skills/nano-add-postman-collection/SKILL.md b/Console.Localization/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Console.Localization/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Console.Localization/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Localization/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Console.Localization/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Localization/.github/prompts/nano-add-data-provider.prompt.md b/Console.Localization/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Console.Localization/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Localization/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Localization/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Console.Localization/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Console.Localization/AGENTS.md b/Console.Localization/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Console.Localization/AGENTS.md +++ b/Console.Localization/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Console.Logging.Log4Net/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Console.Logging.Log4Net/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Console.Logging.Log4Net/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.Logging.Log4Net/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Logging.Log4Net/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Logging.Log4Net/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Console.Logging.Log4Net/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Logging.Log4Net/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Logging.Log4Net/.claude/skills/nano-add-postman-collection/SKILL.md b/Console.Logging.Log4Net/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Console.Logging.Log4Net/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.Logging.Log4Net/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Console.Logging.Log4Net/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Logging.Log4Net/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Logging.Log4Net/.github/prompts/nano-add-data-provider.prompt.md b/Console.Logging.Log4Net/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Logging.Log4Net/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Logging.Log4Net/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Console.Logging.Log4Net/AGENTS.md b/Console.Logging.Log4Net/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Console.Logging.Log4Net/AGENTS.md +++ b/Console.Logging.Log4Net/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Console.Logging.Microsoft/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Console.Logging.Microsoft/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Console.Logging.Microsoft/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.Logging.Microsoft/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Logging.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Logging.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Console.Logging.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Logging.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Logging.Microsoft/.claude/skills/nano-add-postman-collection/SKILL.md b/Console.Logging.Microsoft/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Console.Logging.Microsoft/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.Logging.Microsoft/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Console.Logging.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Logging.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Logging.Microsoft/.github/prompts/nano-add-data-provider.prompt.md b/Console.Logging.Microsoft/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Logging.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Logging.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Console.Logging.Microsoft/AGENTS.md b/Console.Logging.Microsoft/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Console.Logging.Microsoft/AGENTS.md +++ b/Console.Logging.Microsoft/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Console.Logging.NLog/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Console.Logging.NLog/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Console.Logging.NLog/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.Logging.NLog/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Logging.NLog/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Logging.NLog/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Console.Logging.NLog/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Logging.NLog/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Logging.NLog/.claude/skills/nano-add-postman-collection/SKILL.md b/Console.Logging.NLog/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Console.Logging.NLog/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.Logging.NLog/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Console.Logging.NLog/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Logging.NLog/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Console.Logging.NLog/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Logging.NLog/.github/prompts/nano-add-data-provider.prompt.md b/Console.Logging.NLog/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Console.Logging.NLog/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Logging.NLog/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Logging.NLog/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Console.Logging.NLog/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Console.Logging.NLog/AGENTS.md b/Console.Logging.NLog/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Console.Logging.NLog/AGENTS.md +++ b/Console.Logging.NLog/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Console.Logging.Serilog/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Console.Logging.Serilog/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Console.Logging.Serilog/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.Logging.Serilog/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Logging.Serilog/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Logging.Serilog/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Console.Logging.Serilog/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Logging.Serilog/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Logging.Serilog/.claude/skills/nano-add-postman-collection/SKILL.md b/Console.Logging.Serilog/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Console.Logging.Serilog/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.Logging.Serilog/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Console.Logging.Serilog/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Logging.Serilog/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Logging.Serilog/.github/prompts/nano-add-data-provider.prompt.md b/Console.Logging.Serilog/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Logging.Serilog/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Logging.Serilog/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Console.Logging.Serilog/AGENTS.md b/Console.Logging.Serilog/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Console.Logging.Serilog/AGENTS.md +++ b/Console.Logging.Serilog/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Console.StartupTasks/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Console.StartupTasks/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.StartupTasks/.claude/skills/nano-add-data-provider/SKILL.md b/Console.StartupTasks/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md b/Console.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Console.StartupTasks/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.StartupTasks/.github/prompts/nano-add-data-provider.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Console.StartupTasks/AGENTS.md b/Console.StartupTasks/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Console.StartupTasks/AGENTS.md +++ b/Console.StartupTasks/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Console.Storage.Azure/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Console.Storage.Azure/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Console.Storage.Azure/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.Storage.Azure/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Storage.Azure/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Storage.Azure/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Console.Storage.Azure/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Storage.Azure/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Storage.Azure/.claude/skills/nano-add-postman-collection/SKILL.md b/Console.Storage.Azure/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Console.Storage.Azure/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.Storage.Azure/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Console.Storage.Azure/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Storage.Azure/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Console.Storage.Azure/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Storage.Azure/.github/prompts/nano-add-data-provider.prompt.md b/Console.Storage.Azure/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Console.Storage.Azure/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Storage.Azure/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Storage.Azure/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Console.Storage.Azure/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Console.Storage.Azure/AGENTS.md b/Console.Storage.Azure/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Console.Storage.Azure/AGENTS.md +++ b/Console.Storage.Azure/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Console.Storage.Local/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Console.Storage.Local/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Console.Storage.Local/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.Storage.Local/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Storage.Local/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Storage.Local/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Console.Storage.Local/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Storage.Local/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Storage.Local/.claude/skills/nano-add-postman-collection/SKILL.md b/Console.Storage.Local/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Console.Storage.Local/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.Storage.Local/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Console.Storage.Local/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Storage.Local/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Console.Storage.Local/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Storage.Local/.github/prompts/nano-add-data-provider.prompt.md b/Console.Storage.Local/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Console.Storage.Local/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Storage.Local/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Storage.Local/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Console.Storage.Local/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Console.Storage.Local/AGENTS.md b/Console.Storage.Local/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Console.Storage.Local/AGENTS.md +++ b/Console.Storage.Local/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Console.Workers/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Console.Workers/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Console.Workers/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Workers/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Workers/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Console.Workers/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Workers/.claude/skills/nano-add-postman-collection/SKILL.md b/Console.Workers/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Console.Workers/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Console.Workers/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Workers/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Console.Workers/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console.Workers/.github/prompts/nano-add-data-provider.prompt.md b/Console.Workers/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Console.Workers/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Workers/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Workers/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Console.Workers/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Console.Workers/AGENTS.md b/Console.Workers/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Console.Workers/AGENTS.md +++ b/Console.Workers/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Console._Blank/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Console._Blank/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Console._Blank/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console._Blank/.claude/skills/nano-add-data-provider/SKILL.md b/Console._Blank/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Console._Blank/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console._Blank/.claude/skills/nano-add-postman-collection/SKILL.md b/Console._Blank/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Console._Blank/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Console._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Console._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Console._Blank/.github/prompts/nano-add-data-provider.prompt.md b/Console._Blank/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Console._Blank/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console._Blank/.github/prompts/nano-add-postman-collection.prompt.md b/Console._Blank/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Console._Blank/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Console._Blank/AGENTS.md b/Console._Blank/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Console._Blank/AGENTS.md +++ b/Console._Blank/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted diff --git a/Web._Blank/.claude/skills/nano-add-custom-endpoint/SKILL.md b/Web._Blank/.claude/skills/nano-add-custom-endpoint/SKILL.md index f1b5f53f..89333b6c 100644 --- a/Web._Blank/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution — this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient — not just "less convenient." Walk through this before scaffoldin calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead — one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start — one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect — say so rather than tagging it silently. Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) — the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** — the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other — a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here — this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Web._Blank/.claude/skills/nano-add-data-provider/SKILL.md b/Web._Blank/.claude/skills/nano-add-data-provider/SKILL.md index 281822fb..5f98d41e 100644 --- a/Web._Blank/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Web._Blank/.claude/skills/nano-add-postman-collection/SKILL.md b/Web._Blank/.claude/skills/nano-add-postman-collection/SKILL.md index 8bd35093..f7391f9d 100644 --- a/Web._Blank/.claude/skills/nano-add-postman-collection/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-postman-collection/SKILL.md @@ -232,8 +232,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create — Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create — a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in @@ -241,8 +241,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own — it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own — it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, not through another Public API's composed endpoint. Use `http://{{host}}:` with that service's literal host port, read from the nested service block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique diff --git a/Web._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md b/Web._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md index e63075e5..ce8cb9db 100644 --- a/Web._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -19,8 +19,7 @@ contract another application will call, so scaffolding it also means scaffolding half of that same contract - one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word @@ -60,14 +59,12 @@ insufficient - not just "less convenient." Walk through this before scaffolding calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. -- **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types (e.g. "roles - owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls - glued together in one Public API action is fine the first time; the same two calls duplicated - again in a second and third action is a sign the composition belongs on the *target* service as - a real custom endpoint instead - one round trip, one place the logic lives, instead of the same - non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before - promoting it. +- **A combined result built from several generic calls belongs on the owning service.** A union across + two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") + is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the + *target* service from the start - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. The same two calls showing up in a second action is + a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" @@ -109,15 +106,26 @@ surface, not a free side-effect - say so rather than tagging it silently. Not always obvious from the request alone - ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** (e.g. `Api.Platform`/`Api.Admin` in this solution) - the action - composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an - existing custom client method) into one response; it has no `IRepository` of its own. Go to - **Public API path** below. +- **Public API controller** - the action composes one or more injected Api Clients + (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one + response; it has no `IRepository` of its own. Go to **Public API path** below. - **Internal service controller** - the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. +**Where the logic goes.** A Public API action stays simple: fetch then act, or check then act (for +example a lookup or an ownership check, then a create, edit or delete). A check on the request, or on +what the action just fetched, that rejects with a `BadRequestException` before acting is part of that and +stays in the Public API. Complicated logic always +belongs in the internal service, as one custom action behind its Api Client: complicated rules, +deciding something from which other rows exist, or building a combined result or flags from several +calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the +internal service endpoint instead and have the Public API call just that. Combining calls to +different services is the Public API's job. Controllers never share logic with each other - a +service puts shared logic in a repository extension method (an `IRepository` extension in the +app's `Extensions/` folder). + ## Step 3 - Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask @@ -479,6 +487,9 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. +- **Return what the caller needs.** An action that changes something returns the data the caller will + show or use next (for example the finished overview), so the Public API never makes a second call + just to fetch it after the action. This is best practice, not an absolute rule. - **Use `this.Repository`/`this.Eventing`, not the raw primary-constructor parameter, inside the action body.** On an entity controller (`BaseEntityController<...>` and friends), the primary constructor's `repository`/`eventing` parameters are already passed to the base constructor: diff --git a/Web._Blank/.github/prompts/nano-add-data-provider.prompt.md b/Web._Blank/.github/prompts/nano-add-data-provider.prompt.md index b38da06b..8c893c7b 100644 --- a/Web._Blank/.github/prompts/nano-add-data-provider.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Accounts` → `accountsDb`, `Api.Admin` - → `adminDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` + → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Web._Blank/.github/prompts/nano-add-postman-collection.prompt.md b/Web._Blank/.github/prompts/nano-add-postman-collection.prompt.md index dca9168b..52023088 100644 --- a/Web._Blank/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-postman-collection.prompt.md @@ -225,8 +225,8 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - Api.Platform's Postman collection can't test anything before a real, finalized User -exists, but Api.Platform itself has no signup of its own; a Public API's read-only reference data +create - a Public API's Postman collection can't test anything before a real, finalized User +exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in @@ -234,8 +234,8 @@ Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock th own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - (Api.Admin, Api.Platform) has no data of its own - it's always some internal service - (`Svc.Accounts`, `Svc.Assets`, etc.) that actually owns the entity, per AGENTS.md's Public API vs + has no data of its own - it's always some internal service + that actually owns the entity, per AGENTS.md's Public API vs internal service section. Go straight there, using that service's own base URL scheme (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the diff --git a/Web._Blank/AGENTS.md b/Web._Blank/AGENTS.md index 58600e18..c816d4d7 100644 --- a/Web._Blank/AGENTS.md +++ b/Web._Blank/AGENTS.md @@ -1947,6 +1947,25 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **Complicated logic always belongs in the internal service, not in the Public API.** A Public API action stays +simple: fetch then act (a get, then an edit), or check then act (a lookup or an ownership check, then a create, +edit or delete). A check on the request, or on what the action just fetched, that rejects with a +`BadRequestException` before acting is part of that and stays in the Public API (for example rejecting a request that +allows no login method, or a duplicate found by a lookup). Complicated rules, a decision made from which other rows exist, or a combined result or flags built +from several calls are one custom method on the owning service, exposed through its own Api Client method, and the +Public API only maps what it returns. That is exactly the case the [Core Principle](#core-principle--built-in-before-custom) +allows a custom endpoint for. Combining calls to different services is the Public API's job. The service then +enforces and reports a rule from one place, so a response or a button can never say yes where the service would +say no. + +Best practice: a service action that changes something returns the data the caller needs next (for example the +finished overview), so the Public API never makes a second call just to fetch it after the action. + +⚠ **Controllers never share logic with each other.** A controller must not call another controller's helper, and +two controllers must not each carry a copy of the same private method. In an internal service put shared logic in +a repository extension method (an `IRepository` extension in the app's `Extensions/` folder) that every controller +calls. + ⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an @@ -2278,7 +2297,7 @@ Controller `///` comments feed the public Swagger, so keep them short and safe t - The controller class `` says what the controller is for in 5-8 words (`Manages saved reports and how they are shared.`), not just the entity name (`Reports.`). - A brief rule or condition is fine when it helps the caller and is not sensitive. - No implementation details or other internals: no other service or application names, method names, or internal rules. -- Never put `///` on private or internal members. +- Never put `///` on private or internal members, in a controller or anywhere else in the code. #### Request Validation @@ -2985,7 +3004,8 @@ the matching capability interface from [Data Models](#data-models): `Data:Repository:UseAutoSave` (default `true`) governs whether each mutating call commits immediately. To make several repository calls atomic, wrap them in `Repository.ExecuteInTransactionAsync(async ct => { ... }, ct)` — every call inside commits together or rolls back together, even with `UseAutoSave: true`. It runs through the provider's -retry strategy, so the action may run more than once and must be safe to repeat. Alternatively, set `UseAutoSave: +retry strategy, so the action may run more than once and must be safe to repeat. If a transaction is already open on +the context, the action joins it (runs once, the outer transaction decides the outcome). Alternatively, set `UseAutoSave: false` and call `Repository.SaveChangesAsync()` once yourself at the end (one save, no explicit transaction). ⚠ `AddAndGetAsync`/`UpdateAndGetAsync` **always** save regardless of `UseAutoSave` — they need the row persisted From fddc2f1f1bd0779a406249599278045ff43b3bd4 Mon Sep 17 00:00:00 2001 From: vivet Date: Thu, 8 Oct 2026 09:26:24 +0200 Subject: [PATCH 4/4] Updated Skills, Prompts and AGENTS.md. Updated incorrect namespace for mappings Changed how entity base classes maps properties --- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.ApiClients.Audit/AGENTS.md | 32 ++- .../Criterias/ExampleQueryCriteria.cs | 2 +- .../Data/Mappings/ExampleMapping.cs | 4 +- .../Data/Mappings/ExampleNavigationMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.ApiClients.Entity/AGENTS.md | 32 ++- .../Criterias/ExampleQueryCriteria.cs | 2 +- .../Data/Mappings/ExampleMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.ApiClients.RootLogIn/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.ApiClients/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Auth.External.Custom/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Auth.External.Microsoft/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Auth.RootLogin/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Authorization/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.ContentNegotiation/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- Api.Cookies/.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Cookies/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.CustomConfigSection/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.CustomMiddleware/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.CustomService/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Data.Audit/AGENTS.md | 32 ++- .../Criterias/ExampleQueryCriteria.cs | 2 +- .../Data/Mappings/ExampleMapping.cs | 4 +- .../Data/Mappings/ExampleNavigationMapping.cs | 4 +- .../Data/Mappings/ExampleNoAuditMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Data.EntityEvents/AGENTS.md | 32 ++- .../Criterias/DefaultQueryCriteria.cs | 2 +- .../Data/Mappings/CustomerMapping.cs | 4 +- .../Data/Mappings/AddressMapping.cs | 4 +- .../Data/Mappings/CustomerMapping.cs | 4 +- .../Data/Mappings/OrderMapping.cs | 4 +- .../Data/Mappings/PersonMapping.cs | 4 +- .../Data/Mappings/ProfileMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Data.Identity.Auth.ApiKey/AGENTS.md | 32 ++- .../Data/Mappings/UserMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- .../AGENTS.md | 32 ++- .../Data/Mappings/UserMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Data.Identity.Auth.Jwt/AGENTS.md | 32 ++- .../Data/Mappings/UserMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Data.Identity/AGENTS.md | 32 ++- .../Data/Mappings/UserMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Data.InMemory/AGENTS.md | 32 ++- .../Criterias/ExampleQueryCriteria.cs | 2 +- .../ExampleCreatableAndUpdatableMapping.cs | 4 +- .../Data/Mappings/ExampleCreatableMapping.cs | 4 +- .../Data/Mappings/ExampleDeletableMapping.cs | 4 +- .../Data/Mappings/ExampleMapping.cs | 4 +- .../Data/Mappings/ExampleUpdatableMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Data.LazyLoading/AGENTS.md | 32 ++- .../Criterias/ExampleQueryCriteria.cs | 2 +- .../Data/Mappings/ExampleMapping.cs | 4 +- .../ExampleRelationIncludedMapping.cs | 4 +- .../Data/Mappings/ExampleRelationMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Data.MySql.Collation/AGENTS.md | 32 ++- .../Criterias/ExampleQueryCriteria.cs | 2 +- .../Data/Mappings/ExampleMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Data.MySql.Mappings/AGENTS.md | 32 ++- .../ExampleNormalizedQueryCriteria.cs | 2 +- .../Criterias/ExampleQueryCriteria.cs | 2 +- .../Data/Mappings/ExampleJsonMapping.cs | 4 +- .../Data/Mappings/ExampleNormalizedMapping.cs | 4 +- .../Data/Mappings/ExampleOwnedMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Data.MySql.Spatial/AGENTS.md | 32 ++- .../Criterias/ExampleQueryCriteria.cs | 2 +- .../Data/Mappings/ExampleMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Data.MySql.StoredProcedures/AGENTS.md | 32 ++- .../Data/Mappings/ExampleMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Data.MySql.Views/AGENTS.md | 32 ++- .../Criterias/ExampleQueryCriteria.cs | 2 +- .../Data/Mappings/ExampleMapping.cs | 4 +- .../Data/Mappings/ExampleViewMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Data.MySql/AGENTS.md | 32 ++- .../Criterias/ExampleQueryCriteria.cs | 2 +- .../ExampleCreatableAndUpdatableMapping.cs | 4 +- .../Data/Mappings/ExampleCreatableMapping.cs | 4 +- .../Data/Mappings/ExampleDeletableMapping.cs | 4 +- .../Data/Mappings/ExampleMapping.cs | 4 +- .../Data/Mappings/ExampleUpdatableMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Data.PostgreSQL.Spatial/AGENTS.md | 32 ++- .../Criterias/ExampleQueryCriteria.cs | 2 +- .../Data/Mappings/ExampleMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Data.PostgreSQL/AGENTS.md | 32 ++- .../Criterias/ExampleQueryCriteria.cs | 2 +- .../ExampleCreatableAndUpdatableMapping.cs | 4 +- .../Data/Mappings/ExampleCreatableMapping.cs | 4 +- .../Data/Mappings/ExampleDeletableMapping.cs | 4 +- .../Data/Mappings/ExampleMapping.cs | 4 +- .../Data/Mappings/ExampleUpdatableMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Data.Repository.AutoSave/AGENTS.md | 32 ++- .../Data/Mappings/ExampleMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Data.Repository.Includes/AGENTS.md | 32 ++- .../Data/Mappings/CustomerMapping.cs | 4 +- .../Data/Mappings/CustomerProfileMapping.cs | 4 +- .../Data/Mappings/OrderMapping.cs | 4 +- .../Data/Mappings/PaymentMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Data.SoftDelete/AGENTS.md | 32 ++- .../Criterias/ExampleQueryCriteria.cs | 2 +- .../Data/Mappings/ExampleMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Data.SqLite/AGENTS.md | 32 ++- .../Criterias/ExampleQueryCriteria.cs | 2 +- .../ExampleCreatableAndUpdatableMapping.cs | 4 +- .../Data/Mappings/ExampleCreatableMapping.cs | 4 +- .../Data/Mappings/ExampleDeletableMapping.cs | 4 +- .../Data/Mappings/ExampleMapping.cs | 4 +- .../Data/Mappings/ExampleUpdatableMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Data.SqlServer.Spatial/AGENTS.md | 32 ++- .../Criterias/ExampleQueryCriteria.cs | 2 +- .../Data/Mappings/ExampleMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Data.SqlServer/AGENTS.md | 32 ++- .../Criterias/ExampleQueryCriteria.cs | 2 +- .../ExampleCreatableAndUpdatableMapping.cs | 4 +- .../Data/Mappings/ExampleCreatableMapping.cs | 4 +- .../Data/Mappings/ExampleDeletableMapping.cs | 4 +- .../Data/Mappings/ExampleMapping.cs | 4 +- .../Data/Mappings/ExampleUpdatableMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Data.Triggers/AGENTS.md | 32 ++- .../Criterias/ExampleQueryCriteria.cs | 2 +- .../Criterias/ExampleTriggerQueryCriteria.cs | 2 +- .../Data/Mappings/ExampleMapping.cs | 4 +- .../Data/Mappings/ExampleTriggerMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Documentation.Csp/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Documentation/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.ErrorHandling/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Eventing.RabbitMq/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.HealthChecks/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Hosting.Http/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Hosting.Https/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Hosting.MultipartLimits/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Localization/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Logging.Log4Net/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Logging.Microsoft/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Logging.NLog/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Logging.Serilog/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- Api.Metrics/.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Metrics/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.MultipartJson/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- .../AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- .../AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.PolicyHeaders.Cors/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.PolicyHeaders.ForwardedHeaders/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.PolicyHeaders.FrameOptions/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.PolicyHeaders.Hsts/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.PolicyHeaders.ReferrerPolicy/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.PolicyHeaders.Robots/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.PolicyHeaders.XssProtection/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.RequestTracing/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.ResponseCache/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.ResponseCompression/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- Api.Session/.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Session/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.StartupTasks/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.StaticFiles/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Storage.Azure/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Storage.Local/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- Api.TimeZone/.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.TimeZone/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.Versioning/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- Api.VirusScan/.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api.VirusScan/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- Api._Blank/.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Api._Blank/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Console.CustomConfigSection/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Console.CustomService/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Console.Data.InMemory/AGENTS.md | 32 ++- .../Data/Mappings/ExampleMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Console.Data.MySql/AGENTS.md | 32 ++- .../Data/Mappings/ExampleMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Console.Data.PostgreSQL/AGENTS.md | 32 ++- .../Data/Mappings/ExampleMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Console.Data.SqLite/AGENTS.md | 32 ++- .../Data/Mappings/ExampleMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Console.Data.SqlServer/AGENTS.md | 32 ++- .../Data/Mappings/ExampleMapping.cs | 4 +- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Console.Eventing.RabbitMq/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Console.ExceptionHandling/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Console.Localization/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Console.Logging.Log4Net/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Console.Logging.Microsoft/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Console.Logging.NLog/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Console.Logging.Serilog/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Console.StartupTasks/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Console.Storage.Azure/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Console.Storage.Local/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Console.Workers/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- .../.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Console._Blank/AGENTS.md | 32 ++- .../.claude/skills/nano-add-entity/SKILL.md | 13 +- .../.claude/skills/nano-add-identity/SKILL.md | 5 +- Web._Blank/.github/copilot-instructions.md | 26 +-- .../nano-add-custom-endpoint.prompt.md | 213 +++++++++--------- .../.github/prompts/nano-add-entity.prompt.md | 155 +++++++------ .../prompts/nano-add-identity.prompt.md | 71 +++--- .../nano-add-postman-collection.prompt.md | 106 +++++---- Web._Blank/AGENTS.md | 32 ++- 823 files changed, 28883 insertions(+), 28557 deletions(-) diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-entity/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-identity/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.ApiClients.Audit/.github/copilot-instructions.md b/Api.ApiClients.Audit/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.ApiClients.Audit/.github/copilot-instructions.md +++ b/Api.ApiClients.Audit/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-entity.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-identity.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-identity.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-postman-collection.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.ApiClients.Audit/AGENTS.md b/Api.ApiClients.Audit/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.ApiClients.Audit/AGENTS.md +++ b/Api.ApiClients.Audit/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.ApiClients.Audit/Api.ApiClients.Audit.Service.Models/Criterias/ExampleQueryCriteria.cs b/Api.ApiClients.Audit/Api.ApiClients.Audit.Service.Models/Criterias/ExampleQueryCriteria.cs index f35de66c..79df8273 100644 --- a/Api.ApiClients.Audit/Api.ApiClients.Audit.Service.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.ApiClients.Audit/Api.ApiClients.Audit.Service.Models/Criterias/ExampleQueryCriteria.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Linq; using DynamicExpression; -using Nano.App.Api.Controllers.Criteria; +using Nano.App.Criteria; namespace Api.ApiClients.Audit.Service.Models.Criterias; diff --git a/Api.ApiClients.Audit/Api.ApiClients.Audit.Service/Data/Mappings/ExampleMapping.cs b/Api.ApiClients.Audit/Api.ApiClients.Audit.Service/Data/Mappings/ExampleMapping.cs index 8b61ff28..90ade960 100644 --- a/Api.ApiClients.Audit/Api.ApiClients.Audit.Service/Data/Mappings/ExampleMapping.cs +++ b/Api.ApiClients.Audit/Api.ApiClients.Audit.Service/Data/Mappings/ExampleMapping.cs @@ -12,12 +12,10 @@ namespace Api.ApiClients.Audit.Service.Data.Mappings; public class ExampleMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.ApiClients.Audit/Api.ApiClients.Audit.Service/Data/Mappings/ExampleNavigationMapping.cs b/Api.ApiClients.Audit/Api.ApiClients.Audit.Service/Data/Mappings/ExampleNavigationMapping.cs index f9269c03..0a600533 100644 --- a/Api.ApiClients.Audit/Api.ApiClients.Audit.Service/Data/Mappings/ExampleNavigationMapping.cs +++ b/Api.ApiClients.Audit/Api.ApiClients.Audit.Service/Data/Mappings/ExampleNavigationMapping.cs @@ -11,12 +11,10 @@ namespace Api.ApiClients.Audit.Service.Data.Mappings; public class ExampleNavigationMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.NavigationName); diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-entity/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-identity/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.ApiClients.Entity/.github/copilot-instructions.md b/Api.ApiClients.Entity/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.ApiClients.Entity/.github/copilot-instructions.md +++ b/Api.ApiClients.Entity/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-entity.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-identity.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-identity.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-postman-collection.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.ApiClients.Entity/AGENTS.md b/Api.ApiClients.Entity/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.ApiClients.Entity/AGENTS.md +++ b/Api.ApiClients.Entity/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.ApiClients.Entity/Api.ApiClients.Entity.Service.Models/Criterias/ExampleQueryCriteria.cs b/Api.ApiClients.Entity/Api.ApiClients.Entity.Service.Models/Criterias/ExampleQueryCriteria.cs index 93981b5c..ab142fc1 100644 --- a/Api.ApiClients.Entity/Api.ApiClients.Entity.Service.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.ApiClients.Entity/Api.ApiClients.Entity.Service.Models/Criterias/ExampleQueryCriteria.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Linq; using DynamicExpression; -using Nano.App.Api.Controllers.Criteria; +using Nano.App.Criteria; namespace Api.ApiClients.Entity.Service.Models.Criterias; diff --git a/Api.ApiClients.Entity/Api.ApiClients.Entity.Service/Data/Mappings/ExampleMapping.cs b/Api.ApiClients.Entity/Api.ApiClients.Entity.Service/Data/Mappings/ExampleMapping.cs index 6a2ed7b4..a7de948a 100644 --- a/Api.ApiClients.Entity/Api.ApiClients.Entity.Service/Data/Mappings/ExampleMapping.cs +++ b/Api.ApiClients.Entity/Api.ApiClients.Entity.Service/Data/Mappings/ExampleMapping.cs @@ -11,12 +11,10 @@ namespace Api.ApiClients.Entity.Service.Data.Mappings; public class ExampleMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-entity/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-identity/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.ApiClients.RootLogIn/.github/copilot-instructions.md b/Api.ApiClients.RootLogIn/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.ApiClients.RootLogIn/.github/copilot-instructions.md +++ b/Api.ApiClients.RootLogIn/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-entity.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-identity.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-identity.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-postman-collection.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.ApiClients.RootLogIn/AGENTS.md b/Api.ApiClients.RootLogIn/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.ApiClients.RootLogIn/AGENTS.md +++ b/Api.ApiClients.RootLogIn/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.ApiClients/.claude/skills/nano-add-identity/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.ApiClients/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.ApiClients/.github/copilot-instructions.md b/Api.ApiClients/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.ApiClients/.github/copilot-instructions.md +++ b/Api.ApiClients/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.ApiClients/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.ApiClients/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.ApiClients/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md b/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.ApiClients/.github/prompts/nano-add-identity.prompt.md b/Api.ApiClients/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.ApiClients/.github/prompts/nano-add-identity.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.ApiClients/.github/prompts/nano-add-postman-collection.prompt.md b/Api.ApiClients/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.ApiClients/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.ApiClients/AGENTS.md b/Api.ApiClients/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.ApiClients/AGENTS.md +++ b/Api.ApiClients/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-identity/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Auth.External.Custom/.github/copilot-instructions.md b/Api.Auth.External.Custom/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Auth.External.Custom/.github/copilot-instructions.md +++ b/Api.Auth.External.Custom/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-identity.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Auth.External.Custom/AGENTS.md b/Api.Auth.External.Custom/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Auth.External.Custom/AGENTS.md +++ b/Api.Auth.External.Custom/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-entity/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Auth.External.Microsoft/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-identity/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Auth.External.Microsoft/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Auth.External.Microsoft/.github/copilot-instructions.md b/Api.Auth.External.Microsoft/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Auth.External.Microsoft/.github/copilot-instructions.md +++ b/Api.Auth.External.Microsoft/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Auth.External.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-entity.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Auth.External.Microsoft/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-identity.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Auth.External.Microsoft/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Auth.External.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Auth.External.Microsoft/AGENTS.md b/Api.Auth.External.Microsoft/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Auth.External.Microsoft/AGENTS.md +++ b/Api.Auth.External.Microsoft/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Auth.RootLogin/.claude/skills/nano-add-entity/SKILL.md b/Api.Auth.RootLogin/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Auth.RootLogin/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Auth.RootLogin/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Auth.RootLogin/.claude/skills/nano-add-identity/SKILL.md b/Api.Auth.RootLogin/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Auth.RootLogin/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Auth.RootLogin/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Auth.RootLogin/.github/copilot-instructions.md b/Api.Auth.RootLogin/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Auth.RootLogin/.github/copilot-instructions.md +++ b/Api.Auth.RootLogin/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Auth.RootLogin/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Auth.RootLogin/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Auth.RootLogin/.github/prompts/nano-add-entity.prompt.md b/Api.Auth.RootLogin/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Auth.RootLogin/.github/prompts/nano-add-identity.prompt.md b/Api.Auth.RootLogin/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Auth.RootLogin/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Auth.RootLogin/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Auth.RootLogin/AGENTS.md b/Api.Auth.RootLogin/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Auth.RootLogin/AGENTS.md +++ b/Api.Auth.RootLogin/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Authorization/.claude/skills/nano-add-entity/SKILL.md b/Api.Authorization/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Authorization/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Authorization/.claude/skills/nano-add-identity/SKILL.md b/Api.Authorization/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Authorization/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Authorization/.github/copilot-instructions.md b/Api.Authorization/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Authorization/.github/copilot-instructions.md +++ b/Api.Authorization/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Authorization/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Authorization/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Authorization/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Authorization/.github/prompts/nano-add-entity.prompt.md b/Api.Authorization/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Authorization/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Authorization/.github/prompts/nano-add-identity.prompt.md b/Api.Authorization/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Authorization/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Authorization/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Authorization/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Authorization/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Authorization/AGENTS.md b/Api.Authorization/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Authorization/AGENTS.md +++ b/Api.Authorization/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.ContentNegotiation/.claude/skills/nano-add-entity/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.ContentNegotiation/.claude/skills/nano-add-identity/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.ContentNegotiation/.github/copilot-instructions.md b/Api.ContentNegotiation/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.ContentNegotiation/.github/copilot-instructions.md +++ b/Api.ContentNegotiation/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-entity.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-identity.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-identity.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-postman-collection.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.ContentNegotiation/AGENTS.md b/Api.ContentNegotiation/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.ContentNegotiation/AGENTS.md +++ b/Api.ContentNegotiation/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Cookies/.claude/skills/nano-add-entity/SKILL.md b/Api.Cookies/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Cookies/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Cookies/.claude/skills/nano-add-identity/SKILL.md b/Api.Cookies/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Cookies/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Cookies/.github/copilot-instructions.md b/Api.Cookies/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Cookies/.github/copilot-instructions.md +++ b/Api.Cookies/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Cookies/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Cookies/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Cookies/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Cookies/.github/prompts/nano-add-entity.prompt.md b/Api.Cookies/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Cookies/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Cookies/.github/prompts/nano-add-identity.prompt.md b/Api.Cookies/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Cookies/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Cookies/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Cookies/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Cookies/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Cookies/AGENTS.md b/Api.Cookies/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Cookies/AGENTS.md +++ b/Api.Cookies/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.CustomConfigSection/.claude/skills/nano-add-identity/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.CustomConfigSection/.github/copilot-instructions.md b/Api.CustomConfigSection/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.CustomConfigSection/.github/copilot-instructions.md +++ b/Api.CustomConfigSection/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-identity.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-identity.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.CustomConfigSection/AGENTS.md b/Api.CustomConfigSection/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.CustomConfigSection/AGENTS.md +++ b/Api.CustomConfigSection/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.CustomMiddleware/.claude/skills/nano-add-entity/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.CustomMiddleware/.claude/skills/nano-add-identity/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.CustomMiddleware/.github/copilot-instructions.md b/Api.CustomMiddleware/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.CustomMiddleware/.github/copilot-instructions.md +++ b/Api.CustomMiddleware/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-entity.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-entity.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-identity.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-identity.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-postman-collection.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.CustomMiddleware/AGENTS.md b/Api.CustomMiddleware/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.CustomMiddleware/AGENTS.md +++ b/Api.CustomMiddleware/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.CustomService/.claude/skills/nano-add-entity/SKILL.md b/Api.CustomService/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.CustomService/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.CustomService/.claude/skills/nano-add-identity/SKILL.md b/Api.CustomService/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.CustomService/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.CustomService/.github/copilot-instructions.md b/Api.CustomService/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.CustomService/.github/copilot-instructions.md +++ b/Api.CustomService/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.CustomService/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.CustomService/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.CustomService/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.CustomService/.github/prompts/nano-add-entity.prompt.md b/Api.CustomService/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.CustomService/.github/prompts/nano-add-entity.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.CustomService/.github/prompts/nano-add-identity.prompt.md b/Api.CustomService/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.CustomService/.github/prompts/nano-add-identity.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.CustomService/.github/prompts/nano-add-postman-collection.prompt.md b/Api.CustomService/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.CustomService/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.CustomService/AGENTS.md b/Api.CustomService/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.CustomService/AGENTS.md +++ b/Api.CustomService/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Data.Audit/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.Audit/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Data.Audit/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Audit/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Data.Audit/.claude/skills/nano-add-identity/SKILL.md b/Api.Data.Audit/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Data.Audit/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Data.Audit/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Data.Audit/.github/copilot-instructions.md b/Api.Data.Audit/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Data.Audit/.github/copilot-instructions.md +++ b/Api.Data.Audit/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Data.Audit/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.Audit/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Data.Audit/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Data.Audit/.github/prompts/nano-add-entity.prompt.md b/Api.Data.Audit/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Data.Audit/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Data.Audit/.github/prompts/nano-add-identity.prompt.md b/Api.Data.Audit/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Data.Audit/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Data.Audit/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.Audit/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Data.Audit/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Data.Audit/AGENTS.md b/Api.Data.Audit/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Data.Audit/AGENTS.md +++ b/Api.Data.Audit/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Data.Audit/Api.Data.Audit.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.Audit/Api.Data.Audit.Models/Criterias/ExampleQueryCriteria.cs index b98f0e56..d48bdb58 100644 --- a/Api.Data.Audit/Api.Data.Audit.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.Audit/Api.Data.Audit.Models/Criterias/ExampleQueryCriteria.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Linq; using DynamicExpression; -using Nano.App.Api.Controllers.Criteria; +using Nano.App.Criteria; namespace Api.Data.Audit.Models.Criterias; diff --git a/Api.Data.Audit/Api.Data.Audit/Data/Mappings/ExampleMapping.cs b/Api.Data.Audit/Api.Data.Audit/Data/Mappings/ExampleMapping.cs index 8ad77cee..b2500c29 100644 --- a/Api.Data.Audit/Api.Data.Audit/Data/Mappings/ExampleMapping.cs +++ b/Api.Data.Audit/Api.Data.Audit/Data/Mappings/ExampleMapping.cs @@ -12,12 +12,10 @@ namespace Api.Data.Audit.Data.Mappings; public class ExampleMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.Audit/Api.Data.Audit/Data/Mappings/ExampleNavigationMapping.cs b/Api.Data.Audit/Api.Data.Audit/Data/Mappings/ExampleNavigationMapping.cs index 642f2122..923ee8c7 100644 --- a/Api.Data.Audit/Api.Data.Audit/Data/Mappings/ExampleNavigationMapping.cs +++ b/Api.Data.Audit/Api.Data.Audit/Data/Mappings/ExampleNavigationMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.Audit.Data.Mappings; public class ExampleNavigationMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.NavigationName); diff --git a/Api.Data.Audit/Api.Data.Audit/Data/Mappings/ExampleNoAuditMapping.cs b/Api.Data.Audit/Api.Data.Audit/Data/Mappings/ExampleNoAuditMapping.cs index 54ad4ad7..b353e75f 100644 --- a/Api.Data.Audit/Api.Data.Audit/Data/Mappings/ExampleNoAuditMapping.cs +++ b/Api.Data.Audit/Api.Data.Audit/Data/Mappings/ExampleNoAuditMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.Audit.Data.Mappings; public class ExampleNoAuditMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } diff --git a/Api.Data.EntityEvents/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.EntityEvents/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Data.EntityEvents/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.EntityEvents/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Data.EntityEvents/.claude/skills/nano-add-identity/SKILL.md b/Api.Data.EntityEvents/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Data.EntityEvents/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Data.EntityEvents/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Data.EntityEvents/.github/copilot-instructions.md b/Api.Data.EntityEvents/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Data.EntityEvents/.github/copilot-instructions.md +++ b/Api.Data.EntityEvents/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Data.EntityEvents/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.EntityEvents/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Data.EntityEvents/.github/prompts/nano-add-entity.prompt.md b/Api.Data.EntityEvents/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Data.EntityEvents/.github/prompts/nano-add-identity.prompt.md b/Api.Data.EntityEvents/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Data.EntityEvents/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.EntityEvents/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Data.EntityEvents/AGENTS.md b/Api.Data.EntityEvents/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Data.EntityEvents/AGENTS.md +++ b/Api.Data.EntityEvents/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Data.EntityEvents/Api.Data.EntityEvents.Models/Criterias/DefaultQueryCriteria.cs b/Api.Data.EntityEvents/Api.Data.EntityEvents.Models/Criterias/DefaultQueryCriteria.cs index f73873a8..75695003 100644 --- a/Api.Data.EntityEvents/Api.Data.EntityEvents.Models/Criterias/DefaultQueryCriteria.cs +++ b/Api.Data.EntityEvents/Api.Data.EntityEvents.Models/Criterias/DefaultQueryCriteria.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using DynamicExpression; -using Nano.App.Api.Controllers.Criteria; +using Nano.App.Criteria; namespace Api.Data.EntityEvents.Models.Criterias; diff --git a/Api.Data.EntityEvents/Api.Data.EntityEvents.Subscriber/Data/Mappings/CustomerMapping.cs b/Api.Data.EntityEvents/Api.Data.EntityEvents.Subscriber/Data/Mappings/CustomerMapping.cs index 080eaa31..594f8a52 100644 --- a/Api.Data.EntityEvents/Api.Data.EntityEvents.Subscriber/Data/Mappings/CustomerMapping.cs +++ b/Api.Data.EntityEvents/Api.Data.EntityEvents.Subscriber/Data/Mappings/CustomerMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.EntityEvents.Subscriber.Data.Mappings; public class CustomerMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Identitifer) .IsRequired(); diff --git a/Api.Data.EntityEvents/Api.Data.EntityEvents/Data/Mappings/AddressMapping.cs b/Api.Data.EntityEvents/Api.Data.EntityEvents/Data/Mappings/AddressMapping.cs index 6c3de7be..dbdf7217 100644 --- a/Api.Data.EntityEvents/Api.Data.EntityEvents/Data/Mappings/AddressMapping.cs +++ b/Api.Data.EntityEvents/Api.Data.EntityEvents/Data/Mappings/AddressMapping.cs @@ -9,12 +9,10 @@ namespace Api.Data.EntityEvents.Data.Mappings; public class AddressMapping : BaseEntityMapping
{ /// - public override void Configure(EntityTypeBuilder
builder) + protected override void ConfigureEntity(EntityTypeBuilder
builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Street) .IsRequired(); diff --git a/Api.Data.EntityEvents/Api.Data.EntityEvents/Data/Mappings/CustomerMapping.cs b/Api.Data.EntityEvents/Api.Data.EntityEvents/Data/Mappings/CustomerMapping.cs index 43eb1a75..69d4862a 100644 --- a/Api.Data.EntityEvents/Api.Data.EntityEvents/Data/Mappings/CustomerMapping.cs +++ b/Api.Data.EntityEvents/Api.Data.EntityEvents/Data/Mappings/CustomerMapping.cs @@ -10,12 +10,10 @@ namespace Api.Data.EntityEvents.Data.Mappings; public class CustomerMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name) .IsRequired(); diff --git a/Api.Data.EntityEvents/Api.Data.EntityEvents/Data/Mappings/OrderMapping.cs b/Api.Data.EntityEvents/Api.Data.EntityEvents/Data/Mappings/OrderMapping.cs index 17610e87..9316c0f4 100644 --- a/Api.Data.EntityEvents/Api.Data.EntityEvents/Data/Mappings/OrderMapping.cs +++ b/Api.Data.EntityEvents/Api.Data.EntityEvents/Data/Mappings/OrderMapping.cs @@ -9,12 +9,10 @@ namespace Api.Data.EntityEvents.Data.Mappings; public class OrderMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .HasOne(x => x.Customer) .WithMany(x => x.Orders) diff --git a/Api.Data.EntityEvents/Api.Data.EntityEvents/Data/Mappings/PersonMapping.cs b/Api.Data.EntityEvents/Api.Data.EntityEvents/Data/Mappings/PersonMapping.cs index 441a62ab..7c042109 100644 --- a/Api.Data.EntityEvents/Api.Data.EntityEvents/Data/Mappings/PersonMapping.cs +++ b/Api.Data.EntityEvents/Api.Data.EntityEvents/Data/Mappings/PersonMapping.cs @@ -9,12 +9,10 @@ namespace Api.Data.EntityEvents.Data.Mappings; public class PersonMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Identitifer) .IsRequired() diff --git a/Api.Data.EntityEvents/Api.Data.EntityEvents/Data/Mappings/ProfileMapping.cs b/Api.Data.EntityEvents/Api.Data.EntityEvents/Data/Mappings/ProfileMapping.cs index afd5b7d5..cdb2d783 100644 --- a/Api.Data.EntityEvents/Api.Data.EntityEvents/Data/Mappings/ProfileMapping.cs +++ b/Api.Data.EntityEvents/Api.Data.EntityEvents/Data/Mappings/ProfileMapping.cs @@ -9,12 +9,10 @@ namespace Api.Data.EntityEvents.Data.Mappings; public class ProfileMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .HasOne(x => x.Address) .WithOne(x => x.Profile); diff --git a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-identity/SKILL.md b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Data.Identity.Auth.ApiKey/.github/copilot-instructions.md b/Api.Data.Identity.Auth.ApiKey/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Data.Identity.Auth.ApiKey/.github/copilot-instructions.md +++ b/Api.Data.Identity.Auth.ApiKey/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-entity.prompt.md b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-identity.prompt.md b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Data.Identity.Auth.ApiKey/AGENTS.md b/Api.Data.Identity.Auth.ApiKey/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Data.Identity.Auth.ApiKey/AGENTS.md +++ b/Api.Data.Identity.Auth.ApiKey/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Data.Identity.Auth.ApiKey/Api.Data.Identity.Auth.ApiKey/Data/Mappings/UserMapping.cs b/Api.Data.Identity.Auth.ApiKey/Api.Data.Identity.Auth.ApiKey/Data/Mappings/UserMapping.cs index 5da560ef..8dbd59be 100644 --- a/Api.Data.Identity.Auth.ApiKey/Api.Data.Identity.Auth.ApiKey/Data/Mappings/UserMapping.cs +++ b/Api.Data.Identity.Auth.ApiKey/Api.Data.Identity.Auth.ApiKey/Data/Mappings/UserMapping.cs @@ -9,13 +9,11 @@ namespace Api.Data.Identity.Auth.ApiKey.Data.Mappings; public class UserMapping : BaseEntityUserMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { if (builder == null) throw new ArgumentNullException(nameof(builder)); - base.Configure(builder); - builder .Property(x => x.Name) .HasMaxLength(128) diff --git a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-identity/SKILL.md b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Data.Identity.Auth.External.Custom/.github/copilot-instructions.md b/Api.Data.Identity.Auth.External.Custom/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Data.Identity.Auth.External.Custom/.github/copilot-instructions.md +++ b/Api.Data.Identity.Auth.External.Custom/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-identity.prompt.md b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Data.Identity.Auth.External.Custom/AGENTS.md b/Api.Data.Identity.Auth.External.Custom/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Data.Identity.Auth.External.Custom/AGENTS.md +++ b/Api.Data.Identity.Auth.External.Custom/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Data.Identity.Auth.External.Custom/Api.Data.Identity.Auth.External.Custom/Data/Mappings/UserMapping.cs b/Api.Data.Identity.Auth.External.Custom/Api.Data.Identity.Auth.External.Custom/Data/Mappings/UserMapping.cs index be5206c1..11552fac 100644 --- a/Api.Data.Identity.Auth.External.Custom/Api.Data.Identity.Auth.External.Custom/Data/Mappings/UserMapping.cs +++ b/Api.Data.Identity.Auth.External.Custom/Api.Data.Identity.Auth.External.Custom/Data/Mappings/UserMapping.cs @@ -9,13 +9,11 @@ namespace Api.Data.Identity.Auth.External.Custom.Data.Mappings; public class UserMapping : BaseEntityUserMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { if (builder == null) throw new ArgumentNullException(nameof(builder)); - base.Configure(builder); - builder .Property(x => x.Name) .HasMaxLength(128) diff --git a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-identity/SKILL.md b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Data.Identity.Auth.Jwt/.github/copilot-instructions.md b/Api.Data.Identity.Auth.Jwt/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Data.Identity.Auth.Jwt/.github/copilot-instructions.md +++ b/Api.Data.Identity.Auth.Jwt/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-entity.prompt.md b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-identity.prompt.md b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Data.Identity.Auth.Jwt/AGENTS.md b/Api.Data.Identity.Auth.Jwt/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Data.Identity.Auth.Jwt/AGENTS.md +++ b/Api.Data.Identity.Auth.Jwt/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Data.Identity.Auth.Jwt/Api.Data.Identity.Auth.Jwt/Data/Mappings/UserMapping.cs b/Api.Data.Identity.Auth.Jwt/Api.Data.Identity.Auth.Jwt/Data/Mappings/UserMapping.cs index 7ee8ae00..cabb9c86 100644 --- a/Api.Data.Identity.Auth.Jwt/Api.Data.Identity.Auth.Jwt/Data/Mappings/UserMapping.cs +++ b/Api.Data.Identity.Auth.Jwt/Api.Data.Identity.Auth.Jwt/Data/Mappings/UserMapping.cs @@ -9,13 +9,11 @@ namespace Api.Data.Identity.Auth.Jwt.Data.Mappings; public class UserMapping : BaseEntityUserMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { if (builder == null) throw new ArgumentNullException(nameof(builder)); - base.Configure(builder); - builder .Property(x => x.Name) .HasMaxLength(128) diff --git a/Api.Data.Identity/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.Identity/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Data.Identity/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Identity/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Data.Identity/.claude/skills/nano-add-identity/SKILL.md b/Api.Data.Identity/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Data.Identity/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Data.Identity/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Data.Identity/.github/copilot-instructions.md b/Api.Data.Identity/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Data.Identity/.github/copilot-instructions.md +++ b/Api.Data.Identity/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Data.Identity/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.Identity/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Data.Identity/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Data.Identity/.github/prompts/nano-add-entity.prompt.md b/Api.Data.Identity/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Data.Identity/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Data.Identity/.github/prompts/nano-add-identity.prompt.md b/Api.Data.Identity/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Data.Identity/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Data.Identity/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.Identity/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Data.Identity/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Data.Identity/AGENTS.md b/Api.Data.Identity/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Data.Identity/AGENTS.md +++ b/Api.Data.Identity/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Data.Identity/Api.Data.Identity/Data/Mappings/UserMapping.cs b/Api.Data.Identity/Api.Data.Identity/Data/Mappings/UserMapping.cs index e910480b..d87d1840 100644 --- a/Api.Data.Identity/Api.Data.Identity/Data/Mappings/UserMapping.cs +++ b/Api.Data.Identity/Api.Data.Identity/Data/Mappings/UserMapping.cs @@ -9,13 +9,11 @@ namespace Api.Data.Identity.Data.Mappings; public class UserMapping : BaseEntityUserMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { if (builder == null) throw new ArgumentNullException(nameof(builder)); - base.Configure(builder); - builder .Property(x => x.Name) .HasMaxLength(128) diff --git a/Api.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Data.InMemory/.claude/skills/nano-add-identity/SKILL.md b/Api.Data.InMemory/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Data.InMemory/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Data.InMemory/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Data.InMemory/.github/copilot-instructions.md b/Api.Data.InMemory/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Data.InMemory/.github/copilot-instructions.md +++ b/Api.Data.InMemory/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Data.InMemory/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.InMemory/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Data.InMemory/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Data.InMemory/.github/prompts/nano-add-entity.prompt.md b/Api.Data.InMemory/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Data.InMemory/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Data.InMemory/.github/prompts/nano-add-identity.prompt.md b/Api.Data.InMemory/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Data.InMemory/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Data.InMemory/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.InMemory/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Data.InMemory/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Data.InMemory/AGENTS.md b/Api.Data.InMemory/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Data.InMemory/AGENTS.md +++ b/Api.Data.InMemory/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Data.InMemory/Api.Data.InMemory.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.InMemory/Api.Data.InMemory.Models/Criterias/ExampleQueryCriteria.cs index 859c7482..afea6960 100644 --- a/Api.Data.InMemory/Api.Data.InMemory.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.InMemory/Api.Data.InMemory.Models/Criterias/ExampleQueryCriteria.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Linq; using DynamicExpression; -using Nano.App.Api.Controllers.Criteria; +using Nano.App.Criteria; namespace Api.Data.InMemory.Models.Criterias; diff --git a/Api.Data.InMemory/Api.Data.InMemory/Data/Mappings/ExampleCreatableAndUpdatableMapping.cs b/Api.Data.InMemory/Api.Data.InMemory/Data/Mappings/ExampleCreatableAndUpdatableMapping.cs index de1b56ff..27dc66d9 100644 --- a/Api.Data.InMemory/Api.Data.InMemory/Data/Mappings/ExampleCreatableAndUpdatableMapping.cs +++ b/Api.Data.InMemory/Api.Data.InMemory/Data/Mappings/ExampleCreatableAndUpdatableMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.InMemory.Data.Mappings; public class ExampleCreatableAndUpdatableMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.InMemory/Api.Data.InMemory/Data/Mappings/ExampleCreatableMapping.cs b/Api.Data.InMemory/Api.Data.InMemory/Data/Mappings/ExampleCreatableMapping.cs index cec926ea..310f1975 100644 --- a/Api.Data.InMemory/Api.Data.InMemory/Data/Mappings/ExampleCreatableMapping.cs +++ b/Api.Data.InMemory/Api.Data.InMemory/Data/Mappings/ExampleCreatableMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.InMemory.Data.Mappings; public class ExampleCreatableMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.InMemory/Api.Data.InMemory/Data/Mappings/ExampleDeletableMapping.cs b/Api.Data.InMemory/Api.Data.InMemory/Data/Mappings/ExampleDeletableMapping.cs index 864e4dbf..2f1af036 100644 --- a/Api.Data.InMemory/Api.Data.InMemory/Data/Mappings/ExampleDeletableMapping.cs +++ b/Api.Data.InMemory/Api.Data.InMemory/Data/Mappings/ExampleDeletableMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.InMemory.Data.Mappings; public class ExampleDeletableMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.InMemory/Api.Data.InMemory/Data/Mappings/ExampleMapping.cs b/Api.Data.InMemory/Api.Data.InMemory/Data/Mappings/ExampleMapping.cs index 11696401..134f92bf 100644 --- a/Api.Data.InMemory/Api.Data.InMemory/Data/Mappings/ExampleMapping.cs +++ b/Api.Data.InMemory/Api.Data.InMemory/Data/Mappings/ExampleMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.InMemory.Data.Mappings; public class ExampleMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.InMemory/Api.Data.InMemory/Data/Mappings/ExampleUpdatableMapping.cs b/Api.Data.InMemory/Api.Data.InMemory/Data/Mappings/ExampleUpdatableMapping.cs index 67221228..2e80a569 100644 --- a/Api.Data.InMemory/Api.Data.InMemory/Data/Mappings/ExampleUpdatableMapping.cs +++ b/Api.Data.InMemory/Api.Data.InMemory/Data/Mappings/ExampleUpdatableMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.InMemory.Data.Mappings; public class ExampleUpdatableMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.LazyLoading/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.LazyLoading/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Data.LazyLoading/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.LazyLoading/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Data.LazyLoading/.claude/skills/nano-add-identity/SKILL.md b/Api.Data.LazyLoading/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Data.LazyLoading/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Data.LazyLoading/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Data.LazyLoading/.github/copilot-instructions.md b/Api.Data.LazyLoading/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Data.LazyLoading/.github/copilot-instructions.md +++ b/Api.Data.LazyLoading/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Data.LazyLoading/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.LazyLoading/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Data.LazyLoading/.github/prompts/nano-add-entity.prompt.md b/Api.Data.LazyLoading/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Data.LazyLoading/.github/prompts/nano-add-identity.prompt.md b/Api.Data.LazyLoading/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Data.LazyLoading/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.LazyLoading/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Data.LazyLoading/AGENTS.md b/Api.Data.LazyLoading/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Data.LazyLoading/AGENTS.md +++ b/Api.Data.LazyLoading/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Data.LazyLoading/Api.Data.LazyLoading.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.LazyLoading/Api.Data.LazyLoading.Models/Criterias/ExampleQueryCriteria.cs index 9882acfe..4899169f 100644 --- a/Api.Data.LazyLoading/Api.Data.LazyLoading.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.LazyLoading/Api.Data.LazyLoading.Models/Criterias/ExampleQueryCriteria.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using DynamicExpression; -using Nano.App.Api.Controllers.Criteria; +using Nano.App.Criteria; namespace Api.Data.LazyLoading.Models.Criterias; diff --git a/Api.Data.LazyLoading/Api.Data.LazyLoading/Data/Mappings/ExampleMapping.cs b/Api.Data.LazyLoading/Api.Data.LazyLoading/Data/Mappings/ExampleMapping.cs index 0c49d1c8..a9d517ab 100644 --- a/Api.Data.LazyLoading/Api.Data.LazyLoading/Data/Mappings/ExampleMapping.cs +++ b/Api.Data.LazyLoading/Api.Data.LazyLoading/Data/Mappings/ExampleMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.LazyLoading.Data.Mappings; public class ExampleMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.LazyLoading/Api.Data.LazyLoading/Data/Mappings/ExampleRelationIncludedMapping.cs b/Api.Data.LazyLoading/Api.Data.LazyLoading/Data/Mappings/ExampleRelationIncludedMapping.cs index fc03feb5..b0696b67 100644 --- a/Api.Data.LazyLoading/Api.Data.LazyLoading/Data/Mappings/ExampleRelationIncludedMapping.cs +++ b/Api.Data.LazyLoading/Api.Data.LazyLoading/Data/Mappings/ExampleRelationIncludedMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.LazyLoading.Data.Mappings; public class ExampleRelationIncludedMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .HasOne(x => x.Example) .WithMany(x => x.IncludedRelations) diff --git a/Api.Data.LazyLoading/Api.Data.LazyLoading/Data/Mappings/ExampleRelationMapping.cs b/Api.Data.LazyLoading/Api.Data.LazyLoading/Data/Mappings/ExampleRelationMapping.cs index 7fbc34e4..92e20a40 100644 --- a/Api.Data.LazyLoading/Api.Data.LazyLoading/Data/Mappings/ExampleRelationMapping.cs +++ b/Api.Data.LazyLoading/Api.Data.LazyLoading/Data/Mappings/ExampleRelationMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.LazyLoading.Data.Mappings; public class ExampleRelationMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .HasOne(x => x.Example) .WithMany(x => x.Relations) diff --git a/Api.Data.MySql.Collation/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.MySql.Collation/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Data.MySql.Collation/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.MySql.Collation/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Data.MySql.Collation/.claude/skills/nano-add-identity/SKILL.md b/Api.Data.MySql.Collation/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Data.MySql.Collation/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Data.MySql.Collation/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Data.MySql.Collation/.github/copilot-instructions.md b/Api.Data.MySql.Collation/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Data.MySql.Collation/.github/copilot-instructions.md +++ b/Api.Data.MySql.Collation/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Data.MySql.Collation/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.MySql.Collation/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Data.MySql.Collation/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.MySql.Collation/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Data.MySql.Collation/.github/prompts/nano-add-entity.prompt.md b/Api.Data.MySql.Collation/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Data.MySql.Collation/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.MySql.Collation/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Data.MySql.Collation/.github/prompts/nano-add-identity.prompt.md b/Api.Data.MySql.Collation/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Data.MySql.Collation/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Data.MySql.Collation/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Data.MySql.Collation/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.MySql.Collation/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Data.MySql.Collation/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.MySql.Collation/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Data.MySql.Collation/AGENTS.md b/Api.Data.MySql.Collation/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Data.MySql.Collation/AGENTS.md +++ b/Api.Data.MySql.Collation/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Data.MySql.Collation/Api.Data.MySql.Collation.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.MySql.Collation/Api.Data.MySql.Collation.Models/Criterias/ExampleQueryCriteria.cs index 9020b2bb..51548e14 100644 --- a/Api.Data.MySql.Collation/Api.Data.MySql.Collation.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.MySql.Collation/Api.Data.MySql.Collation.Models/Criterias/ExampleQueryCriteria.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Linq; using DynamicExpression; -using Nano.App.Api.Controllers.Criteria; +using Nano.App.Criteria; namespace Api.Data.MySql.Collation.Models.Criterias; diff --git a/Api.Data.MySql.Collation/Api.Data.MySql.Collation/Data/Mappings/ExampleMapping.cs b/Api.Data.MySql.Collation/Api.Data.MySql.Collation/Data/Mappings/ExampleMapping.cs index 670874df..010f1b42 100644 --- a/Api.Data.MySql.Collation/Api.Data.MySql.Collation/Data/Mappings/ExampleMapping.cs +++ b/Api.Data.MySql.Collation/Api.Data.MySql.Collation/Data/Mappings/ExampleMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.MySql.Collation.Data.Mappings; public class ExampleMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.MySql.Mappings/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.MySql.Mappings/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Data.MySql.Mappings/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.MySql.Mappings/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Data.MySql.Mappings/.claude/skills/nano-add-identity/SKILL.md b/Api.Data.MySql.Mappings/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Data.MySql.Mappings/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Data.MySql.Mappings/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Data.MySql.Mappings/.github/copilot-instructions.md b/Api.Data.MySql.Mappings/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Data.MySql.Mappings/.github/copilot-instructions.md +++ b/Api.Data.MySql.Mappings/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Data.MySql.Mappings/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.MySql.Mappings/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Data.MySql.Mappings/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Data.MySql.Mappings/.github/prompts/nano-add-entity.prompt.md b/Api.Data.MySql.Mappings/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Data.MySql.Mappings/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Data.MySql.Mappings/.github/prompts/nano-add-identity.prompt.md b/Api.Data.MySql.Mappings/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Data.MySql.Mappings/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Data.MySql.Mappings/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.MySql.Mappings/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Data.MySql.Mappings/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Data.MySql.Mappings/AGENTS.md b/Api.Data.MySql.Mappings/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Data.MySql.Mappings/AGENTS.md +++ b/Api.Data.MySql.Mappings/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings.Models/Criterias/ExampleNormalizedQueryCriteria.cs b/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings.Models/Criterias/ExampleNormalizedQueryCriteria.cs index d741ec05..2351fae2 100644 --- a/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings.Models/Criterias/ExampleNormalizedQueryCriteria.cs +++ b/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings.Models/Criterias/ExampleNormalizedQueryCriteria.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Linq; using DynamicExpression; -using Nano.App.Api.Controllers.Criteria; +using Nano.App.Criteria; namespace Api.Data.MySql.Mappings.Models.Criterias; diff --git a/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings.Models/Criterias/ExampleQueryCriteria.cs index 856b17b2..a6b4a220 100644 --- a/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings.Models/Criterias/ExampleQueryCriteria.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using DynamicExpression; -using Nano.App.Api.Controllers.Criteria; +using Nano.App.Criteria; namespace Api.Data.MySql.Mappings.Models.Criterias; diff --git a/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings/Data/Mappings/ExampleJsonMapping.cs b/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings/Data/Mappings/ExampleJsonMapping.cs index 785296a2..54bede8a 100644 --- a/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings/Data/Mappings/ExampleJsonMapping.cs +++ b/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings/Data/Mappings/ExampleJsonMapping.cs @@ -13,12 +13,10 @@ namespace Api.Data.MySql.Mappings.Data.Mappings; public class ExampleJsonMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.ProfileAsJson) .HasConversion( diff --git a/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings/Data/Mappings/ExampleNormalizedMapping.cs b/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings/Data/Mappings/ExampleNormalizedMapping.cs index aa1e45bb..35ab2809 100644 --- a/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings/Data/Mappings/ExampleNormalizedMapping.cs +++ b/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings/Data/Mappings/ExampleNormalizedMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.MySql.Mappings.Data.Mappings; public class ExampleNormalizedMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.FirstName) .HasMaxLength(128) diff --git a/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings/Data/Mappings/ExampleOwnedMapping.cs b/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings/Data/Mappings/ExampleOwnedMapping.cs index b691511d..a5a60bef 100644 --- a/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings/Data/Mappings/ExampleOwnedMapping.cs +++ b/Api.Data.MySql.Mappings/Api.Data.MySql.Mappings/Data/Mappings/ExampleOwnedMapping.cs @@ -12,12 +12,10 @@ namespace Api.Data.MySql.Mappings.Data.Mappings; public class ExampleOwnedMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .MapType(x => x.Profile); } diff --git a/Api.Data.MySql.Spatial/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.MySql.Spatial/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Data.MySql.Spatial/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.MySql.Spatial/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Data.MySql.Spatial/.claude/skills/nano-add-identity/SKILL.md b/Api.Data.MySql.Spatial/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Data.MySql.Spatial/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Data.MySql.Spatial/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Data.MySql.Spatial/.github/copilot-instructions.md b/Api.Data.MySql.Spatial/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Data.MySql.Spatial/.github/copilot-instructions.md +++ b/Api.Data.MySql.Spatial/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Data.MySql.Spatial/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.MySql.Spatial/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Data.MySql.Spatial/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Data.MySql.Spatial/.github/prompts/nano-add-entity.prompt.md b/Api.Data.MySql.Spatial/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Data.MySql.Spatial/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Data.MySql.Spatial/.github/prompts/nano-add-identity.prompt.md b/Api.Data.MySql.Spatial/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Data.MySql.Spatial/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Data.MySql.Spatial/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.MySql.Spatial/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Data.MySql.Spatial/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Data.MySql.Spatial/AGENTS.md b/Api.Data.MySql.Spatial/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Data.MySql.Spatial/AGENTS.md +++ b/Api.Data.MySql.Spatial/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Data.MySql.Spatial/Api.Data.MySql.Spatial.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.MySql.Spatial/Api.Data.MySql.Spatial.Models/Criterias/ExampleQueryCriteria.cs index 198440ff..52e5dc67 100644 --- a/Api.Data.MySql.Spatial/Api.Data.MySql.Spatial.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.MySql.Spatial/Api.Data.MySql.Spatial.Models/Criterias/ExampleQueryCriteria.cs @@ -1,5 +1,5 @@ using DynamicExpression; -using Nano.App.Api.Controllers.Criteria; +using Nano.App.Criteria; using NetTopologySuite.Geometries; using System.Collections.Generic; using System.Linq; diff --git a/Api.Data.MySql.Spatial/Api.Data.MySql.Spatial/Data/Mappings/ExampleMapping.cs b/Api.Data.MySql.Spatial/Api.Data.MySql.Spatial/Data/Mappings/ExampleMapping.cs index 657d2a66..cd1514de 100644 --- a/Api.Data.MySql.Spatial/Api.Data.MySql.Spatial/Data/Mappings/ExampleMapping.cs +++ b/Api.Data.MySql.Spatial/Api.Data.MySql.Spatial/Data/Mappings/ExampleMapping.cs @@ -13,12 +13,10 @@ namespace Api.Data.MySql.Spatial.Data.Mappings; public class ExampleMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Point) .HasColumnType("POINT") diff --git a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-identity/SKILL.md b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Data.MySql.StoredProcedures/.github/copilot-instructions.md b/Api.Data.MySql.StoredProcedures/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Data.MySql.StoredProcedures/.github/copilot-instructions.md +++ b/Api.Data.MySql.StoredProcedures/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-entity.prompt.md b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-identity.prompt.md b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Data.MySql.StoredProcedures/AGENTS.md b/Api.Data.MySql.StoredProcedures/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Data.MySql.StoredProcedures/AGENTS.md +++ b/Api.Data.MySql.StoredProcedures/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Data.MySql.StoredProcedures/Api.Data.MySql.StoredProcedures/Data/Mappings/ExampleMapping.cs b/Api.Data.MySql.StoredProcedures/Api.Data.MySql.StoredProcedures/Data/Mappings/ExampleMapping.cs index 10ef53ef..c0b311cf 100644 --- a/Api.Data.MySql.StoredProcedures/Api.Data.MySql.StoredProcedures/Data/Mappings/ExampleMapping.cs +++ b/Api.Data.MySql.StoredProcedures/Api.Data.MySql.StoredProcedures/Data/Mappings/ExampleMapping.cs @@ -12,12 +12,10 @@ namespace Api.Data.MySql.StoredProcedures.Data.Mappings; public class ExampleMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.MySql.Views/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.MySql.Views/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Data.MySql.Views/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.MySql.Views/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Data.MySql.Views/.claude/skills/nano-add-identity/SKILL.md b/Api.Data.MySql.Views/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Data.MySql.Views/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Data.MySql.Views/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Data.MySql.Views/.github/copilot-instructions.md b/Api.Data.MySql.Views/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Data.MySql.Views/.github/copilot-instructions.md +++ b/Api.Data.MySql.Views/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Data.MySql.Views/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.MySql.Views/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Data.MySql.Views/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.MySql.Views/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Data.MySql.Views/.github/prompts/nano-add-entity.prompt.md b/Api.Data.MySql.Views/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Data.MySql.Views/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.MySql.Views/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Data.MySql.Views/.github/prompts/nano-add-identity.prompt.md b/Api.Data.MySql.Views/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Data.MySql.Views/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Data.MySql.Views/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Data.MySql.Views/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.MySql.Views/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Data.MySql.Views/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.MySql.Views/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Data.MySql.Views/AGENTS.md b/Api.Data.MySql.Views/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Data.MySql.Views/AGENTS.md +++ b/Api.Data.MySql.Views/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Data.MySql.Views/Api.Data.MySql.Views.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.MySql.Views/Api.Data.MySql.Views.Models/Criterias/ExampleQueryCriteria.cs index 32eca2ec..b49bb4b8 100644 --- a/Api.Data.MySql.Views/Api.Data.MySql.Views.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.MySql.Views/Api.Data.MySql.Views.Models/Criterias/ExampleQueryCriteria.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Linq; using DynamicExpression; -using Nano.App.Api.Controllers.Criteria; +using Nano.App.Criteria; namespace Api.Data.MySql.Views.Models.Criterias; diff --git a/Api.Data.MySql.Views/Api.Data.MySql.Views/Data/Mappings/ExampleMapping.cs b/Api.Data.MySql.Views/Api.Data.MySql.Views/Data/Mappings/ExampleMapping.cs index 89e6a6e7..151e4af5 100644 --- a/Api.Data.MySql.Views/Api.Data.MySql.Views/Data/Mappings/ExampleMapping.cs +++ b/Api.Data.MySql.Views/Api.Data.MySql.Views/Data/Mappings/ExampleMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.MySql.Views.Data.Mappings; public class ExampleMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } diff --git a/Api.Data.MySql.Views/Api.Data.MySql.Views/Data/Mappings/ExampleViewMapping.cs b/Api.Data.MySql.Views/Api.Data.MySql.Views/Data/Mappings/ExampleViewMapping.cs index fade453c..1302f99c 100644 --- a/Api.Data.MySql.Views/Api.Data.MySql.Views/Data/Mappings/ExampleViewMapping.cs +++ b/Api.Data.MySql.Views/Api.Data.MySql.Views/Data/Mappings/ExampleViewMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.MySql.Views.Data.Mappings; public class ExampleViewMapping : BaseEntityViewMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Id); diff --git a/Api.Data.MySql/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.MySql/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Data.MySql/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.MySql/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Data.MySql/.claude/skills/nano-add-identity/SKILL.md b/Api.Data.MySql/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Data.MySql/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Data.MySql/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Data.MySql/.github/copilot-instructions.md b/Api.Data.MySql/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Data.MySql/.github/copilot-instructions.md +++ b/Api.Data.MySql/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Data.MySql/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.MySql/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Data.MySql/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Data.MySql/.github/prompts/nano-add-entity.prompt.md b/Api.Data.MySql/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Data.MySql/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Data.MySql/.github/prompts/nano-add-identity.prompt.md b/Api.Data.MySql/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Data.MySql/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Data.MySql/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.MySql/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Data.MySql/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Data.MySql/AGENTS.md b/Api.Data.MySql/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Data.MySql/AGENTS.md +++ b/Api.Data.MySql/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Data.MySql/Api.Data.MySql.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.MySql/Api.Data.MySql.Models/Criterias/ExampleQueryCriteria.cs index d239fafa..6da5e5ba 100644 --- a/Api.Data.MySql/Api.Data.MySql.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.MySql/Api.Data.MySql.Models/Criterias/ExampleQueryCriteria.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Linq; using DynamicExpression; -using Nano.App.Api.Controllers.Criteria; +using Nano.App.Criteria; namespace Api.Data.MySql.Models.Criterias; diff --git a/Api.Data.MySql/Api.Data.MySql/Data/Mappings/ExampleCreatableAndUpdatableMapping.cs b/Api.Data.MySql/Api.Data.MySql/Data/Mappings/ExampleCreatableAndUpdatableMapping.cs index 59b3f9fd..32ffc9be 100644 --- a/Api.Data.MySql/Api.Data.MySql/Data/Mappings/ExampleCreatableAndUpdatableMapping.cs +++ b/Api.Data.MySql/Api.Data.MySql/Data/Mappings/ExampleCreatableAndUpdatableMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.MySql.Data.Mappings; public class ExampleCreatableAndUpdatableMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.MySql/Api.Data.MySql/Data/Mappings/ExampleCreatableMapping.cs b/Api.Data.MySql/Api.Data.MySql/Data/Mappings/ExampleCreatableMapping.cs index 563bf221..3cce8788 100644 --- a/Api.Data.MySql/Api.Data.MySql/Data/Mappings/ExampleCreatableMapping.cs +++ b/Api.Data.MySql/Api.Data.MySql/Data/Mappings/ExampleCreatableMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.MySql.Data.Mappings; public class ExampleCreatableMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.MySql/Api.Data.MySql/Data/Mappings/ExampleDeletableMapping.cs b/Api.Data.MySql/Api.Data.MySql/Data/Mappings/ExampleDeletableMapping.cs index 08bbde35..41331b01 100644 --- a/Api.Data.MySql/Api.Data.MySql/Data/Mappings/ExampleDeletableMapping.cs +++ b/Api.Data.MySql/Api.Data.MySql/Data/Mappings/ExampleDeletableMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.MySql.Data.Mappings; public class ExampleDeletableMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.MySql/Api.Data.MySql/Data/Mappings/ExampleMapping.cs b/Api.Data.MySql/Api.Data.MySql/Data/Mappings/ExampleMapping.cs index d33c25ae..f4c19689 100644 --- a/Api.Data.MySql/Api.Data.MySql/Data/Mappings/ExampleMapping.cs +++ b/Api.Data.MySql/Api.Data.MySql/Data/Mappings/ExampleMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.MySql.Data.Mappings; public class ExampleMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.MySql/Api.Data.MySql/Data/Mappings/ExampleUpdatableMapping.cs b/Api.Data.MySql/Api.Data.MySql/Data/Mappings/ExampleUpdatableMapping.cs index ecfb3efb..d0e0db8e 100644 --- a/Api.Data.MySql/Api.Data.MySql/Data/Mappings/ExampleUpdatableMapping.cs +++ b/Api.Data.MySql/Api.Data.MySql/Data/Mappings/ExampleUpdatableMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.MySql.Data.Mappings; public class ExampleUpdatableMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-identity/SKILL.md b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Data.PostgreSQL.Spatial/.github/copilot-instructions.md b/Api.Data.PostgreSQL.Spatial/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Data.PostgreSQL.Spatial/.github/copilot-instructions.md +++ b/Api.Data.PostgreSQL.Spatial/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-entity.prompt.md b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-identity.prompt.md b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Data.PostgreSQL.Spatial/AGENTS.md b/Api.Data.PostgreSQL.Spatial/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Data.PostgreSQL.Spatial/AGENTS.md +++ b/Api.Data.PostgreSQL.Spatial/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Data.PostgreSQL.Spatial/Api.Data.PostgreSQL.Spatial.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.PostgreSQL.Spatial/Api.Data.PostgreSQL.Spatial.Models/Criterias/ExampleQueryCriteria.cs index 9a8820f3..0ad07cb6 100644 --- a/Api.Data.PostgreSQL.Spatial/Api.Data.PostgreSQL.Spatial.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.PostgreSQL.Spatial/Api.Data.PostgreSQL.Spatial.Models/Criterias/ExampleQueryCriteria.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Linq; using DynamicExpression; -using Nano.App.Api.Controllers.Criteria; +using Nano.App.Criteria; using NetTopologySuite.Geometries; namespace Api.Data.PostgreSQL.Spatial.Models.Criterias; diff --git a/Api.Data.PostgreSQL.Spatial/Api.Data.PostgreSQL.Spatial/Data/Mappings/ExampleMapping.cs b/Api.Data.PostgreSQL.Spatial/Api.Data.PostgreSQL.Spatial/Data/Mappings/ExampleMapping.cs index a252eeed..5c938d0f 100644 --- a/Api.Data.PostgreSQL.Spatial/Api.Data.PostgreSQL.Spatial/Data/Mappings/ExampleMapping.cs +++ b/Api.Data.PostgreSQL.Spatial/Api.Data.PostgreSQL.Spatial/Data/Mappings/ExampleMapping.cs @@ -13,12 +13,10 @@ namespace Api.Data.PostgreSQL.Spatial.Data.Mappings; public class ExampleMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Point) .HasColumnType("geography(Point,4326)") diff --git a/Api.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Data.PostgreSQL/.claude/skills/nano-add-identity/SKILL.md b/Api.Data.PostgreSQL/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Data.PostgreSQL/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Data.PostgreSQL/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Data.PostgreSQL/.github/copilot-instructions.md b/Api.Data.PostgreSQL/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Data.PostgreSQL/.github/copilot-instructions.md +++ b/Api.Data.PostgreSQL/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Data.PostgreSQL/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.PostgreSQL/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md b/Api.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Data.PostgreSQL/.github/prompts/nano-add-identity.prompt.md b/Api.Data.PostgreSQL/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Data.PostgreSQL/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.PostgreSQL/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Data.PostgreSQL/AGENTS.md b/Api.Data.PostgreSQL/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Data.PostgreSQL/AGENTS.md +++ b/Api.Data.PostgreSQL/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Data.PostgreSQL/Api.Data.PostgreSQL.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.PostgreSQL/Api.Data.PostgreSQL.Models/Criterias/ExampleQueryCriteria.cs index c7b5cc8a..905a7d9e 100644 --- a/Api.Data.PostgreSQL/Api.Data.PostgreSQL.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.PostgreSQL/Api.Data.PostgreSQL.Models/Criterias/ExampleQueryCriteria.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Linq; using DynamicExpression; -using Nano.App.Api.Controllers.Criteria; +using Nano.App.Criteria; namespace Api.Data.PostgreSQL.Models.Criterias; diff --git a/Api.Data.PostgreSQL/Api.Data.PostgreSQL/Data/Mappings/ExampleCreatableAndUpdatableMapping.cs b/Api.Data.PostgreSQL/Api.Data.PostgreSQL/Data/Mappings/ExampleCreatableAndUpdatableMapping.cs index 466b2e12..4618e834 100644 --- a/Api.Data.PostgreSQL/Api.Data.PostgreSQL/Data/Mappings/ExampleCreatableAndUpdatableMapping.cs +++ b/Api.Data.PostgreSQL/Api.Data.PostgreSQL/Data/Mappings/ExampleCreatableAndUpdatableMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.PostgreSQL.Data.Mappings; public class ExampleCreatableAndUpdatableMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.PostgreSQL/Api.Data.PostgreSQL/Data/Mappings/ExampleCreatableMapping.cs b/Api.Data.PostgreSQL/Api.Data.PostgreSQL/Data/Mappings/ExampleCreatableMapping.cs index 68904cfd..8bac744b 100644 --- a/Api.Data.PostgreSQL/Api.Data.PostgreSQL/Data/Mappings/ExampleCreatableMapping.cs +++ b/Api.Data.PostgreSQL/Api.Data.PostgreSQL/Data/Mappings/ExampleCreatableMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.PostgreSQL.Data.Mappings; public class ExampleCreatableMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.PostgreSQL/Api.Data.PostgreSQL/Data/Mappings/ExampleDeletableMapping.cs b/Api.Data.PostgreSQL/Api.Data.PostgreSQL/Data/Mappings/ExampleDeletableMapping.cs index a7b1ba84..9aed7b0e 100644 --- a/Api.Data.PostgreSQL/Api.Data.PostgreSQL/Data/Mappings/ExampleDeletableMapping.cs +++ b/Api.Data.PostgreSQL/Api.Data.PostgreSQL/Data/Mappings/ExampleDeletableMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.PostgreSQL.Data.Mappings; public class ExampleDeletableMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.PostgreSQL/Api.Data.PostgreSQL/Data/Mappings/ExampleMapping.cs b/Api.Data.PostgreSQL/Api.Data.PostgreSQL/Data/Mappings/ExampleMapping.cs index b8e39f21..ed7ac99c 100644 --- a/Api.Data.PostgreSQL/Api.Data.PostgreSQL/Data/Mappings/ExampleMapping.cs +++ b/Api.Data.PostgreSQL/Api.Data.PostgreSQL/Data/Mappings/ExampleMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.PostgreSQL.Data.Mappings; public class ExampleMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.PostgreSQL/Api.Data.PostgreSQL/Data/Mappings/ExampleUpdatableMapping.cs b/Api.Data.PostgreSQL/Api.Data.PostgreSQL/Data/Mappings/ExampleUpdatableMapping.cs index b65030c2..668b4099 100644 --- a/Api.Data.PostgreSQL/Api.Data.PostgreSQL/Data/Mappings/ExampleUpdatableMapping.cs +++ b/Api.Data.PostgreSQL/Api.Data.PostgreSQL/Data/Mappings/ExampleUpdatableMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.PostgreSQL.Data.Mappings; public class ExampleUpdatableMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-identity/SKILL.md b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Data.Repository.AutoSave/.github/copilot-instructions.md b/Api.Data.Repository.AutoSave/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Data.Repository.AutoSave/.github/copilot-instructions.md +++ b/Api.Data.Repository.AutoSave/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-entity.prompt.md b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-identity.prompt.md b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Data.Repository.AutoSave/AGENTS.md b/Api.Data.Repository.AutoSave/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Data.Repository.AutoSave/AGENTS.md +++ b/Api.Data.Repository.AutoSave/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Data.Repository.AutoSave/Api.Data.Repository.AutoSave/Data/Mappings/ExampleMapping.cs b/Api.Data.Repository.AutoSave/Api.Data.Repository.AutoSave/Data/Mappings/ExampleMapping.cs index cd6d7970..ef45850f 100644 --- a/Api.Data.Repository.AutoSave/Api.Data.Repository.AutoSave/Data/Mappings/ExampleMapping.cs +++ b/Api.Data.Repository.AutoSave/Api.Data.Repository.AutoSave/Data/Mappings/ExampleMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.Repository.AutoSave.Data.Mappings; public class ExampleMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.Repository.Includes/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.Repository.Includes/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Data.Repository.Includes/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Repository.Includes/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Data.Repository.Includes/.claude/skills/nano-add-identity/SKILL.md b/Api.Data.Repository.Includes/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Data.Repository.Includes/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Data.Repository.Includes/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Data.Repository.Includes/.github/copilot-instructions.md b/Api.Data.Repository.Includes/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Data.Repository.Includes/.github/copilot-instructions.md +++ b/Api.Data.Repository.Includes/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Data.Repository.Includes/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.Repository.Includes/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Data.Repository.Includes/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.Repository.Includes/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Data.Repository.Includes/.github/prompts/nano-add-entity.prompt.md b/Api.Data.Repository.Includes/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Data.Repository.Includes/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Repository.Includes/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Data.Repository.Includes/.github/prompts/nano-add-identity.prompt.md b/Api.Data.Repository.Includes/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Data.Repository.Includes/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Data.Repository.Includes/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Data.Repository.Includes/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.Repository.Includes/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Data.Repository.Includes/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.Repository.Includes/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Data.Repository.Includes/AGENTS.md b/Api.Data.Repository.Includes/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Data.Repository.Includes/AGENTS.md +++ b/Api.Data.Repository.Includes/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Data.Repository.Includes/Api.Data.Repository.Includes/Data/Mappings/CustomerMapping.cs b/Api.Data.Repository.Includes/Api.Data.Repository.Includes/Data/Mappings/CustomerMapping.cs index 16e0a485..c7baba98 100644 --- a/Api.Data.Repository.Includes/Api.Data.Repository.Includes/Data/Mappings/CustomerMapping.cs +++ b/Api.Data.Repository.Includes/Api.Data.Repository.Includes/Data/Mappings/CustomerMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.Repository.Includes.Data.Mappings; public class CustomerMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .HasOne(x => x.Profile) .WithOne(x => x.Customer) diff --git a/Api.Data.Repository.Includes/Api.Data.Repository.Includes/Data/Mappings/CustomerProfileMapping.cs b/Api.Data.Repository.Includes/Api.Data.Repository.Includes/Data/Mappings/CustomerProfileMapping.cs index d61d7b62..36d8b158 100644 --- a/Api.Data.Repository.Includes/Api.Data.Repository.Includes/Data/Mappings/CustomerProfileMapping.cs +++ b/Api.Data.Repository.Includes/Api.Data.Repository.Includes/Data/Mappings/CustomerProfileMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.Repository.Includes.Data.Mappings; public class CustomerProfileMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .HasOne(x => x.Customer) .WithOne(x => x.Profile); diff --git a/Api.Data.Repository.Includes/Api.Data.Repository.Includes/Data/Mappings/OrderMapping.cs b/Api.Data.Repository.Includes/Api.Data.Repository.Includes/Data/Mappings/OrderMapping.cs index d2bc2cc8..a15b4e32 100644 --- a/Api.Data.Repository.Includes/Api.Data.Repository.Includes/Data/Mappings/OrderMapping.cs +++ b/Api.Data.Repository.Includes/Api.Data.Repository.Includes/Data/Mappings/OrderMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.Repository.Includes.Data.Mappings; public class OrderMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .HasOne(x => x.Customer) .WithMany(x => x.Orders) diff --git a/Api.Data.Repository.Includes/Api.Data.Repository.Includes/Data/Mappings/PaymentMapping.cs b/Api.Data.Repository.Includes/Api.Data.Repository.Includes/Data/Mappings/PaymentMapping.cs index b2d147ed..a6ed72bd 100644 --- a/Api.Data.Repository.Includes/Api.Data.Repository.Includes/Data/Mappings/PaymentMapping.cs +++ b/Api.Data.Repository.Includes/Api.Data.Repository.Includes/Data/Mappings/PaymentMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.Repository.Includes.Data.Mappings; public class PaymentMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .HasOne(x => x.Order) .WithOne(x => x.Payment); diff --git a/Api.Data.SoftDelete/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.SoftDelete/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Data.SoftDelete/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.SoftDelete/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Data.SoftDelete/.claude/skills/nano-add-identity/SKILL.md b/Api.Data.SoftDelete/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Data.SoftDelete/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Data.SoftDelete/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Data.SoftDelete/.github/copilot-instructions.md b/Api.Data.SoftDelete/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Data.SoftDelete/.github/copilot-instructions.md +++ b/Api.Data.SoftDelete/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Data.SoftDelete/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.SoftDelete/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Data.SoftDelete/.github/prompts/nano-add-entity.prompt.md b/Api.Data.SoftDelete/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Data.SoftDelete/.github/prompts/nano-add-identity.prompt.md b/Api.Data.SoftDelete/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Data.SoftDelete/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.SoftDelete/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Data.SoftDelete/AGENTS.md b/Api.Data.SoftDelete/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Data.SoftDelete/AGENTS.md +++ b/Api.Data.SoftDelete/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Data.SoftDelete/Api.Data.SoftDelete.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.SoftDelete/Api.Data.SoftDelete.Models/Criterias/ExampleQueryCriteria.cs index b5bce1e9..e4b3d5a3 100644 --- a/Api.Data.SoftDelete/Api.Data.SoftDelete.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.SoftDelete/Api.Data.SoftDelete.Models/Criterias/ExampleQueryCriteria.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Linq; using DynamicExpression; -using Nano.App.Api.Controllers.Criteria; +using Nano.App.Criteria; namespace Api.Data.SoftDelete.Models.Criterias; diff --git a/Api.Data.SoftDelete/Api.Data.SoftDelete/Data/Mappings/ExampleMapping.cs b/Api.Data.SoftDelete/Api.Data.SoftDelete/Data/Mappings/ExampleMapping.cs index f16a7a45..43b9bf71 100644 --- a/Api.Data.SoftDelete/Api.Data.SoftDelete/Data/Mappings/ExampleMapping.cs +++ b/Api.Data.SoftDelete/Api.Data.SoftDelete/Data/Mappings/ExampleMapping.cs @@ -12,12 +12,10 @@ namespace Api.Data.SoftDelete.Data.Mappings; public class ExampleMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Data.SqLite/.claude/skills/nano-add-identity/SKILL.md b/Api.Data.SqLite/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Data.SqLite/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Data.SqLite/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Data.SqLite/.github/copilot-instructions.md b/Api.Data.SqLite/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Data.SqLite/.github/copilot-instructions.md +++ b/Api.Data.SqLite/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Data.SqLite/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.SqLite/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Data.SqLite/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Data.SqLite/.github/prompts/nano-add-entity.prompt.md b/Api.Data.SqLite/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Data.SqLite/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Data.SqLite/.github/prompts/nano-add-identity.prompt.md b/Api.Data.SqLite/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Data.SqLite/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Data.SqLite/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.SqLite/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Data.SqLite/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Data.SqLite/AGENTS.md b/Api.Data.SqLite/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Data.SqLite/AGENTS.md +++ b/Api.Data.SqLite/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Data.SqLite/Api.Data.SqLite.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.SqLite/Api.Data.SqLite.Models/Criterias/ExampleQueryCriteria.cs index 0898a54e..0393a6de 100644 --- a/Api.Data.SqLite/Api.Data.SqLite.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.SqLite/Api.Data.SqLite.Models/Criterias/ExampleQueryCriteria.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Linq; using DynamicExpression; -using Nano.App.Api.Controllers.Criteria; +using Nano.App.Criteria; namespace Api.Data.SqLite.Models.Criterias; diff --git a/Api.Data.SqLite/Api.Data.SqLite/Data/Mappings/ExampleCreatableAndUpdatableMapping.cs b/Api.Data.SqLite/Api.Data.SqLite/Data/Mappings/ExampleCreatableAndUpdatableMapping.cs index 04319331..aac3d533 100644 --- a/Api.Data.SqLite/Api.Data.SqLite/Data/Mappings/ExampleCreatableAndUpdatableMapping.cs +++ b/Api.Data.SqLite/Api.Data.SqLite/Data/Mappings/ExampleCreatableAndUpdatableMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.SqLite.Data.Mappings; public class ExampleCreatableAndUpdatableMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.SqLite/Api.Data.SqLite/Data/Mappings/ExampleCreatableMapping.cs b/Api.Data.SqLite/Api.Data.SqLite/Data/Mappings/ExampleCreatableMapping.cs index 45a6bd26..73442da9 100644 --- a/Api.Data.SqLite/Api.Data.SqLite/Data/Mappings/ExampleCreatableMapping.cs +++ b/Api.Data.SqLite/Api.Data.SqLite/Data/Mappings/ExampleCreatableMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.SqLite.Data.Mappings; public class ExampleCreatableMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.SqLite/Api.Data.SqLite/Data/Mappings/ExampleDeletableMapping.cs b/Api.Data.SqLite/Api.Data.SqLite/Data/Mappings/ExampleDeletableMapping.cs index 0eee93ec..b890b885 100644 --- a/Api.Data.SqLite/Api.Data.SqLite/Data/Mappings/ExampleDeletableMapping.cs +++ b/Api.Data.SqLite/Api.Data.SqLite/Data/Mappings/ExampleDeletableMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.SqLite.Data.Mappings; public class ExampleDeletableMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.SqLite/Api.Data.SqLite/Data/Mappings/ExampleMapping.cs b/Api.Data.SqLite/Api.Data.SqLite/Data/Mappings/ExampleMapping.cs index 82a1f096..42b1b24e 100644 --- a/Api.Data.SqLite/Api.Data.SqLite/Data/Mappings/ExampleMapping.cs +++ b/Api.Data.SqLite/Api.Data.SqLite/Data/Mappings/ExampleMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.SqLite.Data.Mappings; public class ExampleMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.SqLite/Api.Data.SqLite/Data/Mappings/ExampleUpdatableMapping.cs b/Api.Data.SqLite/Api.Data.SqLite/Data/Mappings/ExampleUpdatableMapping.cs index 3849ad5a..de2f448a 100644 --- a/Api.Data.SqLite/Api.Data.SqLite/Data/Mappings/ExampleUpdatableMapping.cs +++ b/Api.Data.SqLite/Api.Data.SqLite/Data/Mappings/ExampleUpdatableMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.SqLite.Data.Mappings; public class ExampleUpdatableMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-identity/SKILL.md b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Data.SqlServer.Spatial/.github/copilot-instructions.md b/Api.Data.SqlServer.Spatial/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Data.SqlServer.Spatial/.github/copilot-instructions.md +++ b/Api.Data.SqlServer.Spatial/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-entity.prompt.md b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-identity.prompt.md b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Data.SqlServer.Spatial/AGENTS.md b/Api.Data.SqlServer.Spatial/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Data.SqlServer.Spatial/AGENTS.md +++ b/Api.Data.SqlServer.Spatial/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Data.SqlServer.Spatial/Api.Data.SqlServer.Spatial.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.SqlServer.Spatial/Api.Data.SqlServer.Spatial.Models/Criterias/ExampleQueryCriteria.cs index ad1ace22..6fe94854 100644 --- a/Api.Data.SqlServer.Spatial/Api.Data.SqlServer.Spatial.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.SqlServer.Spatial/Api.Data.SqlServer.Spatial.Models/Criterias/ExampleQueryCriteria.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Linq; using DynamicExpression; -using Nano.App.Api.Controllers.Criteria; +using Nano.App.Criteria; using NetTopologySuite.Geometries; namespace Api.Data.SqlServer.Spatial.Models.Criterias; diff --git a/Api.Data.SqlServer.Spatial/Api.Data.SqlServer.Spatial/Data/Mappings/ExampleMapping.cs b/Api.Data.SqlServer.Spatial/Api.Data.SqlServer.Spatial/Data/Mappings/ExampleMapping.cs index 5552f03c..13358a3b 100644 --- a/Api.Data.SqlServer.Spatial/Api.Data.SqlServer.Spatial/Data/Mappings/ExampleMapping.cs +++ b/Api.Data.SqlServer.Spatial/Api.Data.SqlServer.Spatial/Data/Mappings/ExampleMapping.cs @@ -12,12 +12,10 @@ namespace Api.Data.SqlServer.Spatial.Data.Mappings; public class ExampleMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Point) .HasColumnType("geography") diff --git a/Api.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Data.SqlServer/.claude/skills/nano-add-identity/SKILL.md b/Api.Data.SqlServer/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Data.SqlServer/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Data.SqlServer/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Data.SqlServer/.github/copilot-instructions.md b/Api.Data.SqlServer/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Data.SqlServer/.github/copilot-instructions.md +++ b/Api.Data.SqlServer/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Data.SqlServer/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.SqlServer/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md b/Api.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Data.SqlServer/.github/prompts/nano-add-identity.prompt.md b/Api.Data.SqlServer/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Data.SqlServer/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.SqlServer/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Data.SqlServer/AGENTS.md b/Api.Data.SqlServer/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Data.SqlServer/AGENTS.md +++ b/Api.Data.SqlServer/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Data.SqlServer/Api.Data.SqlServer.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.SqlServer/Api.Data.SqlServer.Models/Criterias/ExampleQueryCriteria.cs index 0129030d..5f58eaa5 100644 --- a/Api.Data.SqlServer/Api.Data.SqlServer.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.SqlServer/Api.Data.SqlServer.Models/Criterias/ExampleQueryCriteria.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Linq; using DynamicExpression; -using Nano.App.Api.Controllers.Criteria; +using Nano.App.Criteria; namespace Api.Data.SqlServer.Models.Criterias; diff --git a/Api.Data.SqlServer/Api.Data.SqlServer/Data/Mappings/ExampleCreatableAndUpdatableMapping.cs b/Api.Data.SqlServer/Api.Data.SqlServer/Data/Mappings/ExampleCreatableAndUpdatableMapping.cs index 67adeef4..73d8f90a 100644 --- a/Api.Data.SqlServer/Api.Data.SqlServer/Data/Mappings/ExampleCreatableAndUpdatableMapping.cs +++ b/Api.Data.SqlServer/Api.Data.SqlServer/Data/Mappings/ExampleCreatableAndUpdatableMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.SqlServer.Data.Mappings; public class ExampleCreatableAndUpdatableMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.SqlServer/Api.Data.SqlServer/Data/Mappings/ExampleCreatableMapping.cs b/Api.Data.SqlServer/Api.Data.SqlServer/Data/Mappings/ExampleCreatableMapping.cs index 66317926..cf896380 100644 --- a/Api.Data.SqlServer/Api.Data.SqlServer/Data/Mappings/ExampleCreatableMapping.cs +++ b/Api.Data.SqlServer/Api.Data.SqlServer/Data/Mappings/ExampleCreatableMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.SqlServer.Data.Mappings; public class ExampleCreatableMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.SqlServer/Api.Data.SqlServer/Data/Mappings/ExampleDeletableMapping.cs b/Api.Data.SqlServer/Api.Data.SqlServer/Data/Mappings/ExampleDeletableMapping.cs index c35832c9..da375a12 100644 --- a/Api.Data.SqlServer/Api.Data.SqlServer/Data/Mappings/ExampleDeletableMapping.cs +++ b/Api.Data.SqlServer/Api.Data.SqlServer/Data/Mappings/ExampleDeletableMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.SqlServer.Data.Mappings; public class ExampleDeletableMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.SqlServer/Api.Data.SqlServer/Data/Mappings/ExampleMapping.cs b/Api.Data.SqlServer/Api.Data.SqlServer/Data/Mappings/ExampleMapping.cs index c840122b..c9070f11 100644 --- a/Api.Data.SqlServer/Api.Data.SqlServer/Data/Mappings/ExampleMapping.cs +++ b/Api.Data.SqlServer/Api.Data.SqlServer/Data/Mappings/ExampleMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.SqlServer.Data.Mappings; public class ExampleMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.SqlServer/Api.Data.SqlServer/Data/Mappings/ExampleUpdatableMapping.cs b/Api.Data.SqlServer/Api.Data.SqlServer/Data/Mappings/ExampleUpdatableMapping.cs index 2183ff74..8a21c411 100644 --- a/Api.Data.SqlServer/Api.Data.SqlServer/Data/Mappings/ExampleUpdatableMapping.cs +++ b/Api.Data.SqlServer/Api.Data.SqlServer/Data/Mappings/ExampleUpdatableMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.SqlServer.Data.Mappings; public class ExampleUpdatableMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.Triggers/.claude/skills/nano-add-entity/SKILL.md b/Api.Data.Triggers/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Data.Triggers/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Data.Triggers/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Data.Triggers/.claude/skills/nano-add-identity/SKILL.md b/Api.Data.Triggers/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Data.Triggers/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Data.Triggers/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Data.Triggers/.github/copilot-instructions.md b/Api.Data.Triggers/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Data.Triggers/.github/copilot-instructions.md +++ b/Api.Data.Triggers/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Data.Triggers/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Data.Triggers/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Data.Triggers/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Data.Triggers/.github/prompts/nano-add-entity.prompt.md b/Api.Data.Triggers/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Data.Triggers/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Data.Triggers/.github/prompts/nano-add-identity.prompt.md b/Api.Data.Triggers/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Data.Triggers/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Data.Triggers/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Data.Triggers/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Data.Triggers/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Data.Triggers/AGENTS.md b/Api.Data.Triggers/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Data.Triggers/AGENTS.md +++ b/Api.Data.Triggers/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Data.Triggers/Api.Data.Triggers.Models/Criterias/ExampleQueryCriteria.cs b/Api.Data.Triggers/Api.Data.Triggers.Models/Criterias/ExampleQueryCriteria.cs index e9546b7e..240381a7 100644 --- a/Api.Data.Triggers/Api.Data.Triggers.Models/Criterias/ExampleQueryCriteria.cs +++ b/Api.Data.Triggers/Api.Data.Triggers.Models/Criterias/ExampleQueryCriteria.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using DynamicExpression; -using Nano.App.Api.Controllers.Criteria; +using Nano.App.Criteria; namespace Api.Data.Triggers.Models.Criterias; diff --git a/Api.Data.Triggers/Api.Data.Triggers.Models/Criterias/ExampleTriggerQueryCriteria.cs b/Api.Data.Triggers/Api.Data.Triggers.Models/Criterias/ExampleTriggerQueryCriteria.cs index d401429f..323bd767 100644 --- a/Api.Data.Triggers/Api.Data.Triggers.Models/Criterias/ExampleTriggerQueryCriteria.cs +++ b/Api.Data.Triggers/Api.Data.Triggers.Models/Criterias/ExampleTriggerQueryCriteria.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using DynamicExpression; -using Nano.App.Api.Controllers.Criteria; +using Nano.App.Criteria; namespace Api.Data.Triggers.Models.Criterias; diff --git a/Api.Data.Triggers/Api.Data.Triggers/Data/Mappings/ExampleMapping.cs b/Api.Data.Triggers/Api.Data.Triggers/Data/Mappings/ExampleMapping.cs index c0849781..1ee7b86f 100644 --- a/Api.Data.Triggers/Api.Data.Triggers/Data/Mappings/ExampleMapping.cs +++ b/Api.Data.Triggers/Api.Data.Triggers/Data/Mappings/ExampleMapping.cs @@ -13,12 +13,10 @@ namespace Api.Data.Triggers.Data.Mappings; public class ExampleMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Api.Data.Triggers/Api.Data.Triggers/Data/Mappings/ExampleTriggerMapping.cs b/Api.Data.Triggers/Api.Data.Triggers/Data/Mappings/ExampleTriggerMapping.cs index 7bc3a37c..1d2e3533 100644 --- a/Api.Data.Triggers/Api.Data.Triggers/Data/Mappings/ExampleTriggerMapping.cs +++ b/Api.Data.Triggers/Api.Data.Triggers/Data/Mappings/ExampleTriggerMapping.cs @@ -11,12 +11,10 @@ namespace Api.Data.Triggers.Data.Mappings; public class ExampleTriggerMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Trigger) .IsRequired(); diff --git a/Api.Documentation.Csp/.claude/skills/nano-add-entity/SKILL.md b/Api.Documentation.Csp/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Documentation.Csp/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Documentation.Csp/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Documentation.Csp/.claude/skills/nano-add-identity/SKILL.md b/Api.Documentation.Csp/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Documentation.Csp/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Documentation.Csp/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Documentation.Csp/.github/copilot-instructions.md b/Api.Documentation.Csp/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Documentation.Csp/.github/copilot-instructions.md +++ b/Api.Documentation.Csp/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Documentation.Csp/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Documentation.Csp/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Documentation.Csp/.github/prompts/nano-add-entity.prompt.md b/Api.Documentation.Csp/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Documentation.Csp/.github/prompts/nano-add-identity.prompt.md b/Api.Documentation.Csp/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Documentation.Csp/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Documentation.Csp/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Documentation.Csp/AGENTS.md b/Api.Documentation.Csp/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Documentation.Csp/AGENTS.md +++ b/Api.Documentation.Csp/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Documentation/.claude/skills/nano-add-entity/SKILL.md b/Api.Documentation/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Documentation/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Documentation/.claude/skills/nano-add-identity/SKILL.md b/Api.Documentation/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Documentation/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Documentation/.github/copilot-instructions.md b/Api.Documentation/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Documentation/.github/copilot-instructions.md +++ b/Api.Documentation/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Documentation/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Documentation/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Documentation/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Documentation/.github/prompts/nano-add-entity.prompt.md b/Api.Documentation/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Documentation/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Documentation/.github/prompts/nano-add-identity.prompt.md b/Api.Documentation/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Documentation/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Documentation/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Documentation/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Documentation/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Documentation/AGENTS.md b/Api.Documentation/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Documentation/AGENTS.md +++ b/Api.Documentation/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.ErrorHandling/.claude/skills/nano-add-entity/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.ErrorHandling/.claude/skills/nano-add-identity/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.ErrorHandling/.github/copilot-instructions.md b/Api.ErrorHandling/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.ErrorHandling/.github/copilot-instructions.md +++ b/Api.ErrorHandling/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.ErrorHandling/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.ErrorHandling/.github/prompts/nano-add-entity.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.ErrorHandling/.github/prompts/nano-add-identity.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-identity.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.ErrorHandling/.github/prompts/nano-add-postman-collection.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.ErrorHandling/AGENTS.md b/Api.ErrorHandling/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.ErrorHandling/AGENTS.md +++ b/Api.ErrorHandling/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md b/Api.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Eventing.RabbitMq/.claude/skills/nano-add-identity/SKILL.md b/Api.Eventing.RabbitMq/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Eventing.RabbitMq/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Eventing.RabbitMq/.github/copilot-instructions.md b/Api.Eventing.RabbitMq/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Eventing.RabbitMq/.github/copilot-instructions.md +++ b/Api.Eventing.RabbitMq/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Eventing.RabbitMq/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Eventing.RabbitMq/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md b/Api.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Eventing.RabbitMq/.github/prompts/nano-add-identity.prompt.md b/Api.Eventing.RabbitMq/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Eventing.RabbitMq/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Eventing.RabbitMq/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Eventing.RabbitMq/AGENTS.md b/Api.Eventing.RabbitMq/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Eventing.RabbitMq/AGENTS.md +++ b/Api.Eventing.RabbitMq/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.HealthChecks/.claude/skills/nano-add-entity/SKILL.md b/Api.HealthChecks/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.HealthChecks/.claude/skills/nano-add-identity/SKILL.md b/Api.HealthChecks/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.HealthChecks/.github/copilot-instructions.md b/Api.HealthChecks/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.HealthChecks/.github/copilot-instructions.md +++ b/Api.HealthChecks/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.HealthChecks/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.HealthChecks/.github/prompts/nano-add-entity.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-entity.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.HealthChecks/.github/prompts/nano-add-identity.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-identity.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.HealthChecks/.github/prompts/nano-add-postman-collection.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.HealthChecks/AGENTS.md b/Api.HealthChecks/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.HealthChecks/AGENTS.md +++ b/Api.HealthChecks/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Hosting.Http/.claude/skills/nano-add-entity/SKILL.md b/Api.Hosting.Http/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Hosting.Http/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Hosting.Http/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Hosting.Http/.claude/skills/nano-add-identity/SKILL.md b/Api.Hosting.Http/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Hosting.Http/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Hosting.Http/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Hosting.Http/.github/copilot-instructions.md b/Api.Hosting.Http/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Hosting.Http/.github/copilot-instructions.md +++ b/Api.Hosting.Http/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Hosting.Http/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Hosting.Http/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Hosting.Http/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Hosting.Http/.github/prompts/nano-add-entity.prompt.md b/Api.Hosting.Http/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Hosting.Http/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Hosting.Http/.github/prompts/nano-add-identity.prompt.md b/Api.Hosting.Http/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Hosting.Http/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Hosting.Http/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Hosting.Http/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Hosting.Http/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Hosting.Http/AGENTS.md b/Api.Hosting.Http/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Hosting.Http/AGENTS.md +++ b/Api.Hosting.Http/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Hosting.Https/.claude/skills/nano-add-entity/SKILL.md b/Api.Hosting.Https/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Hosting.Https/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Hosting.Https/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Hosting.Https/.claude/skills/nano-add-identity/SKILL.md b/Api.Hosting.Https/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Hosting.Https/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Hosting.Https/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Hosting.Https/.github/copilot-instructions.md b/Api.Hosting.Https/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Hosting.Https/.github/copilot-instructions.md +++ b/Api.Hosting.Https/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Hosting.Https/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Hosting.Https/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Hosting.Https/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Hosting.Https/.github/prompts/nano-add-entity.prompt.md b/Api.Hosting.Https/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Hosting.Https/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Hosting.Https/.github/prompts/nano-add-identity.prompt.md b/Api.Hosting.Https/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Hosting.Https/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Hosting.Https/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Hosting.Https/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Hosting.Https/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Hosting.Https/AGENTS.md b/Api.Hosting.Https/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Hosting.Https/AGENTS.md +++ b/Api.Hosting.Https/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-entity/SKILL.md b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-identity/SKILL.md b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Hosting.MultipartLimits/.github/copilot-instructions.md b/Api.Hosting.MultipartLimits/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Hosting.MultipartLimits/.github/copilot-instructions.md +++ b/Api.Hosting.MultipartLimits/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-entity.prompt.md b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-identity.prompt.md b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Hosting.MultipartLimits/AGENTS.md b/Api.Hosting.MultipartLimits/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Hosting.MultipartLimits/AGENTS.md +++ b/Api.Hosting.MultipartLimits/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Localization/.claude/skills/nano-add-entity/SKILL.md b/Api.Localization/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Localization/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Localization/.claude/skills/nano-add-identity/SKILL.md b/Api.Localization/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Localization/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Localization/.github/copilot-instructions.md b/Api.Localization/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Localization/.github/copilot-instructions.md +++ b/Api.Localization/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Localization/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Localization/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Localization/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Localization/.github/prompts/nano-add-entity.prompt.md b/Api.Localization/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Localization/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Localization/.github/prompts/nano-add-identity.prompt.md b/Api.Localization/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Localization/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Localization/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Localization/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Localization/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Localization/AGENTS.md b/Api.Localization/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Localization/AGENTS.md +++ b/Api.Localization/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md b/Api.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Logging.Log4Net/.claude/skills/nano-add-identity/SKILL.md b/Api.Logging.Log4Net/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Logging.Log4Net/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Logging.Log4Net/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Logging.Log4Net/.github/copilot-instructions.md b/Api.Logging.Log4Net/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Logging.Log4Net/.github/copilot-instructions.md +++ b/Api.Logging.Log4Net/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Logging.Log4Net/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Logging.Log4Net/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md b/Api.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Logging.Log4Net/.github/prompts/nano-add-identity.prompt.md b/Api.Logging.Log4Net/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Logging.Log4Net/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Logging.Log4Net/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Logging.Log4Net/AGENTS.md b/Api.Logging.Log4Net/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Logging.Log4Net/AGENTS.md +++ b/Api.Logging.Log4Net/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md b/Api.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Logging.Microsoft/.claude/skills/nano-add-identity/SKILL.md b/Api.Logging.Microsoft/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Logging.Microsoft/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Logging.Microsoft/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Logging.Microsoft/.github/copilot-instructions.md b/Api.Logging.Microsoft/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Logging.Microsoft/.github/copilot-instructions.md +++ b/Api.Logging.Microsoft/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Logging.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Logging.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md b/Api.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Logging.Microsoft/.github/prompts/nano-add-identity.prompt.md b/Api.Logging.Microsoft/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Logging.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Logging.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Logging.Microsoft/AGENTS.md b/Api.Logging.Microsoft/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Logging.Microsoft/AGENTS.md +++ b/Api.Logging.Microsoft/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md b/Api.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Logging.NLog/.claude/skills/nano-add-identity/SKILL.md b/Api.Logging.NLog/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Logging.NLog/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Logging.NLog/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Logging.NLog/.github/copilot-instructions.md b/Api.Logging.NLog/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Logging.NLog/.github/copilot-instructions.md +++ b/Api.Logging.NLog/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Logging.NLog/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Logging.NLog/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Logging.NLog/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Logging.NLog/.github/prompts/nano-add-entity.prompt.md b/Api.Logging.NLog/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Logging.NLog/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Logging.NLog/.github/prompts/nano-add-identity.prompt.md b/Api.Logging.NLog/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Logging.NLog/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Logging.NLog/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Logging.NLog/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Logging.NLog/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Logging.NLog/AGENTS.md b/Api.Logging.NLog/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Logging.NLog/AGENTS.md +++ b/Api.Logging.NLog/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md b/Api.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Logging.Serilog/.claude/skills/nano-add-identity/SKILL.md b/Api.Logging.Serilog/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Logging.Serilog/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Logging.Serilog/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Logging.Serilog/.github/copilot-instructions.md b/Api.Logging.Serilog/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Logging.Serilog/.github/copilot-instructions.md +++ b/Api.Logging.Serilog/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Logging.Serilog/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Logging.Serilog/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md b/Api.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Logging.Serilog/.github/prompts/nano-add-identity.prompt.md b/Api.Logging.Serilog/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Logging.Serilog/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Logging.Serilog/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Logging.Serilog/AGENTS.md b/Api.Logging.Serilog/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Logging.Serilog/AGENTS.md +++ b/Api.Logging.Serilog/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Metrics/.claude/skills/nano-add-entity/SKILL.md b/Api.Metrics/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Metrics/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Metrics/.claude/skills/nano-add-identity/SKILL.md b/Api.Metrics/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Metrics/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Metrics/.github/copilot-instructions.md b/Api.Metrics/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Metrics/.github/copilot-instructions.md +++ b/Api.Metrics/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Metrics/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Metrics/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Metrics/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Metrics/.github/prompts/nano-add-entity.prompt.md b/Api.Metrics/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Metrics/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Metrics/.github/prompts/nano-add-identity.prompt.md b/Api.Metrics/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Metrics/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Metrics/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Metrics/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Metrics/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Metrics/AGENTS.md b/Api.Metrics/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Metrics/AGENTS.md +++ b/Api.Metrics/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.MultipartJson/.claude/skills/nano-add-entity/SKILL.md b/Api.MultipartJson/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.MultipartJson/.claude/skills/nano-add-identity/SKILL.md b/Api.MultipartJson/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.MultipartJson/.github/copilot-instructions.md b/Api.MultipartJson/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.MultipartJson/.github/copilot-instructions.md +++ b/Api.MultipartJson/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.MultipartJson/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.MultipartJson/.github/prompts/nano-add-entity.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-entity.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.MultipartJson/.github/prompts/nano-add-identity.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-identity.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.MultipartJson/.github/prompts/nano-add-postman-collection.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.MultipartJson/AGENTS.md b/Api.MultipartJson/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.MultipartJson/AGENTS.md +++ b/Api.MultipartJson/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-identity/SKILL.md b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/copilot-instructions.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/copilot-instructions.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-identity.prompt.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-identity.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-postman-collection.prompt.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-identity/SKILL.md b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/copilot-instructions.md b/Api.PolicyHeaders.ContentTypeOptions/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/copilot-instructions.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-identity.prompt.md b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-identity.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-postman-collection.prompt.md b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md +++ b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-identity/SKILL.md b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.PolicyHeaders.Cors/.github/copilot-instructions.md b/Api.PolicyHeaders.Cors/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.PolicyHeaders.Cors/.github/copilot-instructions.md +++ b/Api.PolicyHeaders.Cors/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-identity.prompt.md b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-identity.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-postman-collection.prompt.md b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.PolicyHeaders.Cors/AGENTS.md b/Api.PolicyHeaders.Cors/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.PolicyHeaders.Cors/AGENTS.md +++ b/Api.PolicyHeaders.Cors/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-identity/SKILL.md b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/copilot-instructions.md b/Api.PolicyHeaders.ForwardedHeaders/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/copilot-instructions.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-identity.prompt.md b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-identity.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-postman-collection.prompt.md b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md +++ b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-identity/SKILL.md b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.PolicyHeaders.FrameOptions/.github/copilot-instructions.md b/Api.PolicyHeaders.FrameOptions/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/copilot-instructions.md +++ b/Api.PolicyHeaders.FrameOptions/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-identity.prompt.md b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-identity.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-postman-collection.prompt.md b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.PolicyHeaders.FrameOptions/AGENTS.md b/Api.PolicyHeaders.FrameOptions/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.PolicyHeaders.FrameOptions/AGENTS.md +++ b/Api.PolicyHeaders.FrameOptions/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-identity/SKILL.md b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.PolicyHeaders.Hsts/.github/copilot-instructions.md b/Api.PolicyHeaders.Hsts/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.PolicyHeaders.Hsts/.github/copilot-instructions.md +++ b/Api.PolicyHeaders.Hsts/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-identity.prompt.md b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-identity.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-postman-collection.prompt.md b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.PolicyHeaders.Hsts/AGENTS.md b/Api.PolicyHeaders.Hsts/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.PolicyHeaders.Hsts/AGENTS.md +++ b/Api.PolicyHeaders.Hsts/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-identity/SKILL.md b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/copilot-instructions.md b/Api.PolicyHeaders.ReferrerPolicy/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/copilot-instructions.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-identity.prompt.md b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-identity.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-postman-collection.prompt.md b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-identity/SKILL.md b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.PolicyHeaders.Robots/.github/copilot-instructions.md b/Api.PolicyHeaders.Robots/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.PolicyHeaders.Robots/.github/copilot-instructions.md +++ b/Api.PolicyHeaders.Robots/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-identity.prompt.md b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-identity.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-postman-collection.prompt.md b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.PolicyHeaders.Robots/AGENTS.md b/Api.PolicyHeaders.Robots/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.PolicyHeaders.Robots/AGENTS.md +++ b/Api.PolicyHeaders.Robots/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-entity/SKILL.md b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-identity/SKILL.md b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.PolicyHeaders.XssProtection/.github/copilot-instructions.md b/Api.PolicyHeaders.XssProtection/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.PolicyHeaders.XssProtection/.github/copilot-instructions.md +++ b/Api.PolicyHeaders.XssProtection/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-entity.prompt.md b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-entity.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-identity.prompt.md b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-identity.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-postman-collection.prompt.md b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.PolicyHeaders.XssProtection/AGENTS.md b/Api.PolicyHeaders.XssProtection/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.PolicyHeaders.XssProtection/AGENTS.md +++ b/Api.PolicyHeaders.XssProtection/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.RequestTracing/.claude/skills/nano-add-entity/SKILL.md b/Api.RequestTracing/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.RequestTracing/.claude/skills/nano-add-identity/SKILL.md b/Api.RequestTracing/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.RequestTracing/.github/copilot-instructions.md b/Api.RequestTracing/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.RequestTracing/.github/copilot-instructions.md +++ b/Api.RequestTracing/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.RequestTracing/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.RequestTracing/.github/prompts/nano-add-entity.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-entity.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.RequestTracing/.github/prompts/nano-add-identity.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-identity.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.RequestTracing/.github/prompts/nano-add-postman-collection.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.RequestTracing/AGENTS.md b/Api.RequestTracing/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.RequestTracing/AGENTS.md +++ b/Api.RequestTracing/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.ResponseCache/.claude/skills/nano-add-entity/SKILL.md b/Api.ResponseCache/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.ResponseCache/.claude/skills/nano-add-identity/SKILL.md b/Api.ResponseCache/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.ResponseCache/.github/copilot-instructions.md b/Api.ResponseCache/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.ResponseCache/.github/copilot-instructions.md +++ b/Api.ResponseCache/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.ResponseCache/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.ResponseCache/.github/prompts/nano-add-entity.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.ResponseCache/.github/prompts/nano-add-identity.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-identity.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.ResponseCache/.github/prompts/nano-add-postman-collection.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.ResponseCache/AGENTS.md b/Api.ResponseCache/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.ResponseCache/AGENTS.md +++ b/Api.ResponseCache/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.ResponseCompression/.claude/skills/nano-add-entity/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.ResponseCompression/.claude/skills/nano-add-identity/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.ResponseCompression/.github/copilot-instructions.md b/Api.ResponseCompression/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.ResponseCompression/.github/copilot-instructions.md +++ b/Api.ResponseCompression/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.ResponseCompression/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.ResponseCompression/.github/prompts/nano-add-entity.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-entity.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.ResponseCompression/.github/prompts/nano-add-identity.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-identity.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.ResponseCompression/.github/prompts/nano-add-postman-collection.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.ResponseCompression/AGENTS.md b/Api.ResponseCompression/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.ResponseCompression/AGENTS.md +++ b/Api.ResponseCompression/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Session/.claude/skills/nano-add-entity/SKILL.md b/Api.Session/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Session/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Session/.claude/skills/nano-add-identity/SKILL.md b/Api.Session/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Session/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Session/.github/copilot-instructions.md b/Api.Session/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Session/.github/copilot-instructions.md +++ b/Api.Session/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Session/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Session/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Session/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Session/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Session/.github/prompts/nano-add-entity.prompt.md b/Api.Session/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Session/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Session/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Session/.github/prompts/nano-add-identity.prompt.md b/Api.Session/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Session/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Session/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Session/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Session/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Session/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Session/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Session/AGENTS.md b/Api.Session/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Session/AGENTS.md +++ b/Api.Session/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.StartupTasks/.claude/skills/nano-add-entity/SKILL.md b/Api.StartupTasks/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.StartupTasks/.claude/skills/nano-add-identity/SKILL.md b/Api.StartupTasks/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.StartupTasks/.github/copilot-instructions.md b/Api.StartupTasks/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.StartupTasks/.github/copilot-instructions.md +++ b/Api.StartupTasks/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.StartupTasks/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.StartupTasks/.github/prompts/nano-add-entity.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-entity.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.StartupTasks/.github/prompts/nano-add-identity.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-identity.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.StartupTasks/AGENTS.md b/Api.StartupTasks/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.StartupTasks/AGENTS.md +++ b/Api.StartupTasks/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.StaticFiles/.claude/skills/nano-add-entity/SKILL.md b/Api.StaticFiles/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.StaticFiles/.claude/skills/nano-add-identity/SKILL.md b/Api.StaticFiles/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.StaticFiles/.github/copilot-instructions.md b/Api.StaticFiles/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.StaticFiles/.github/copilot-instructions.md +++ b/Api.StaticFiles/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.StaticFiles/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.StaticFiles/.github/prompts/nano-add-entity.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-entity.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.StaticFiles/.github/prompts/nano-add-identity.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-identity.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.StaticFiles/.github/prompts/nano-add-postman-collection.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.StaticFiles/AGENTS.md b/Api.StaticFiles/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.StaticFiles/AGENTS.md +++ b/Api.StaticFiles/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md b/Api.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Storage.Azure/.claude/skills/nano-add-identity/SKILL.md b/Api.Storage.Azure/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Storage.Azure/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Storage.Azure/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Storage.Azure/.github/copilot-instructions.md b/Api.Storage.Azure/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Storage.Azure/.github/copilot-instructions.md +++ b/Api.Storage.Azure/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Storage.Azure/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Storage.Azure/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Storage.Azure/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Storage.Azure/.github/prompts/nano-add-entity.prompt.md b/Api.Storage.Azure/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Storage.Azure/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Storage.Azure/.github/prompts/nano-add-identity.prompt.md b/Api.Storage.Azure/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Storage.Azure/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Storage.Azure/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Storage.Azure/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Storage.Azure/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Storage.Azure/AGENTS.md b/Api.Storage.Azure/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Storage.Azure/AGENTS.md +++ b/Api.Storage.Azure/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Storage.Local/.claude/skills/nano-add-entity/SKILL.md b/Api.Storage.Local/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Storage.Local/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Storage.Local/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Storage.Local/.claude/skills/nano-add-identity/SKILL.md b/Api.Storage.Local/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Storage.Local/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Storage.Local/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Storage.Local/.github/copilot-instructions.md b/Api.Storage.Local/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Storage.Local/.github/copilot-instructions.md +++ b/Api.Storage.Local/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Storage.Local/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Storage.Local/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Storage.Local/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Storage.Local/.github/prompts/nano-add-entity.prompt.md b/Api.Storage.Local/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Storage.Local/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Storage.Local/.github/prompts/nano-add-identity.prompt.md b/Api.Storage.Local/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Storage.Local/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Storage.Local/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Storage.Local/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Storage.Local/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Storage.Local/AGENTS.md b/Api.Storage.Local/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Storage.Local/AGENTS.md +++ b/Api.Storage.Local/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.TimeZone/.claude/skills/nano-add-entity/SKILL.md b/Api.TimeZone/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.TimeZone/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.TimeZone/.claude/skills/nano-add-identity/SKILL.md b/Api.TimeZone/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.TimeZone/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.TimeZone/.github/copilot-instructions.md b/Api.TimeZone/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.TimeZone/.github/copilot-instructions.md +++ b/Api.TimeZone/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.TimeZone/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.TimeZone/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.TimeZone/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.TimeZone/.github/prompts/nano-add-entity.prompt.md b/Api.TimeZone/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.TimeZone/.github/prompts/nano-add-entity.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.TimeZone/.github/prompts/nano-add-identity.prompt.md b/Api.TimeZone/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.TimeZone/.github/prompts/nano-add-identity.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.TimeZone/.github/prompts/nano-add-postman-collection.prompt.md b/Api.TimeZone/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.TimeZone/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.TimeZone/AGENTS.md b/Api.TimeZone/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.TimeZone/AGENTS.md +++ b/Api.TimeZone/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.Versioning/.claude/skills/nano-add-entity/SKILL.md b/Api.Versioning/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.Versioning/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.Versioning/.claude/skills/nano-add-identity/SKILL.md b/Api.Versioning/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.Versioning/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.Versioning/.github/copilot-instructions.md b/Api.Versioning/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.Versioning/.github/copilot-instructions.md +++ b/Api.Versioning/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.Versioning/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.Versioning/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.Versioning/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.Versioning/.github/prompts/nano-add-entity.prompt.md b/Api.Versioning/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.Versioning/.github/prompts/nano-add-entity.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.Versioning/.github/prompts/nano-add-identity.prompt.md b/Api.Versioning/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.Versioning/.github/prompts/nano-add-identity.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.Versioning/.github/prompts/nano-add-postman-collection.prompt.md b/Api.Versioning/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.Versioning/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.Versioning/AGENTS.md b/Api.Versioning/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.Versioning/AGENTS.md +++ b/Api.Versioning/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api.VirusScan/.claude/skills/nano-add-entity/SKILL.md b/Api.VirusScan/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api.VirusScan/.claude/skills/nano-add-entity/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api.VirusScan/.claude/skills/nano-add-identity/SKILL.md b/Api.VirusScan/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api.VirusScan/.claude/skills/nano-add-identity/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api.VirusScan/.github/copilot-instructions.md b/Api.VirusScan/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api.VirusScan/.github/copilot-instructions.md +++ b/Api.VirusScan/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api.VirusScan/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api.VirusScan/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api.VirusScan/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api.VirusScan/.github/prompts/nano-add-entity.prompt.md b/Api.VirusScan/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api.VirusScan/.github/prompts/nano-add-entity.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api.VirusScan/.github/prompts/nano-add-identity.prompt.md b/Api.VirusScan/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api.VirusScan/.github/prompts/nano-add-identity.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api.VirusScan/.github/prompts/nano-add-postman-collection.prompt.md b/Api.VirusScan/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api.VirusScan/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api.VirusScan/AGENTS.md b/Api.VirusScan/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api.VirusScan/AGENTS.md +++ b/Api.VirusScan/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Api._Blank/.claude/skills/nano-add-entity/SKILL.md b/Api._Blank/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Api._Blank/.claude/skills/nano-add-entity/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Api._Blank/.claude/skills/nano-add-identity/SKILL.md b/Api._Blank/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Api._Blank/.claude/skills/nano-add-identity/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Api._Blank/.github/copilot-instructions.md b/Api._Blank/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Api._Blank/.github/copilot-instructions.md +++ b/Api._Blank/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Api._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md b/Api._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Api._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Api._Blank/.github/prompts/nano-add-entity.prompt.md b/Api._Blank/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Api._Blank/.github/prompts/nano-add-entity.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Api._Blank/.github/prompts/nano-add-identity.prompt.md b/Api._Blank/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Api._Blank/.github/prompts/nano-add-identity.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Api._Blank/.github/prompts/nano-add-postman-collection.prompt.md b/Api._Blank/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Api._Blank/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Api._Blank/AGENTS.md b/Api._Blank/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Api._Blank/AGENTS.md +++ b/Api._Blank/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Console.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Console.CustomConfigSection/.claude/skills/nano-add-identity/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-identity/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Console.CustomConfigSection/.github/copilot-instructions.md b/Console.CustomConfigSection/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Console.CustomConfigSection/.github/copilot-instructions.md +++ b/Console.CustomConfigSection/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-identity.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-identity.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Console.CustomConfigSection/AGENTS.md b/Console.CustomConfigSection/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Console.CustomConfigSection/AGENTS.md +++ b/Console.CustomConfigSection/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Console.CustomService/.claude/skills/nano-add-entity/SKILL.md b/Console.CustomService/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Console.CustomService/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Console.CustomService/.claude/skills/nano-add-identity/SKILL.md b/Console.CustomService/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Console.CustomService/.claude/skills/nano-add-identity/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Console.CustomService/.github/copilot-instructions.md b/Console.CustomService/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Console.CustomService/.github/copilot-instructions.md +++ b/Console.CustomService/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Console.CustomService/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.CustomService/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Console.CustomService/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Console.CustomService/.github/prompts/nano-add-entity.prompt.md b/Console.CustomService/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Console.CustomService/.github/prompts/nano-add-entity.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Console.CustomService/.github/prompts/nano-add-identity.prompt.md b/Console.CustomService/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Console.CustomService/.github/prompts/nano-add-identity.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Console.CustomService/.github/prompts/nano-add-postman-collection.prompt.md b/Console.CustomService/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Console.CustomService/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Console.CustomService/AGENTS.md b/Console.CustomService/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Console.CustomService/AGENTS.md +++ b/Console.CustomService/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Console.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md b/Console.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Console.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.InMemory/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Console.Data.InMemory/.claude/skills/nano-add-identity/SKILL.md b/Console.Data.InMemory/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Console.Data.InMemory/.claude/skills/nano-add-identity/SKILL.md +++ b/Console.Data.InMemory/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Console.Data.InMemory/.github/copilot-instructions.md b/Console.Data.InMemory/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Console.Data.InMemory/.github/copilot-instructions.md +++ b/Console.Data.InMemory/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Console.Data.InMemory/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Data.InMemory/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Console.Data.InMemory/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Console.Data.InMemory/.github/prompts/nano-add-entity.prompt.md b/Console.Data.InMemory/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Console.Data.InMemory/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Console.Data.InMemory/.github/prompts/nano-add-identity.prompt.md b/Console.Data.InMemory/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Console.Data.InMemory/.github/prompts/nano-add-identity.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Console.Data.InMemory/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Data.InMemory/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Console.Data.InMemory/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Console.Data.InMemory/AGENTS.md b/Console.Data.InMemory/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Console.Data.InMemory/AGENTS.md +++ b/Console.Data.InMemory/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Console.Data.InMemory/Console.Data.InMemory/Data/Mappings/ExampleMapping.cs b/Console.Data.InMemory/Console.Data.InMemory/Data/Mappings/ExampleMapping.cs index ef3fdeac..fa4ddfaa 100644 --- a/Console.Data.InMemory/Console.Data.InMemory/Data/Mappings/ExampleMapping.cs +++ b/Console.Data.InMemory/Console.Data.InMemory/Data/Mappings/ExampleMapping.cs @@ -11,12 +11,10 @@ namespace Console.Data.InMemory.Data.Mappings; public class ExampleMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Console.Data.MySql/.claude/skills/nano-add-entity/SKILL.md b/Console.Data.MySql/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Console.Data.MySql/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.MySql/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Console.Data.MySql/.claude/skills/nano-add-identity/SKILL.md b/Console.Data.MySql/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Console.Data.MySql/.claude/skills/nano-add-identity/SKILL.md +++ b/Console.Data.MySql/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Console.Data.MySql/.github/copilot-instructions.md b/Console.Data.MySql/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Console.Data.MySql/.github/copilot-instructions.md +++ b/Console.Data.MySql/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Console.Data.MySql/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Data.MySql/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Console.Data.MySql/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Console.Data.MySql/.github/prompts/nano-add-entity.prompt.md b/Console.Data.MySql/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Console.Data.MySql/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Console.Data.MySql/.github/prompts/nano-add-identity.prompt.md b/Console.Data.MySql/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Console.Data.MySql/.github/prompts/nano-add-identity.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Console.Data.MySql/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Data.MySql/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Console.Data.MySql/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Console.Data.MySql/AGENTS.md b/Console.Data.MySql/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Console.Data.MySql/AGENTS.md +++ b/Console.Data.MySql/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Console.Data.MySql/Console.Data.MySql/Data/Mappings/ExampleMapping.cs b/Console.Data.MySql/Console.Data.MySql/Data/Mappings/ExampleMapping.cs index 0b8d424b..2ec99e4b 100644 --- a/Console.Data.MySql/Console.Data.MySql/Data/Mappings/ExampleMapping.cs +++ b/Console.Data.MySql/Console.Data.MySql/Data/Mappings/ExampleMapping.cs @@ -11,12 +11,10 @@ namespace Console.Data.MySql.Data.Mappings; public class ExampleMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Console.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md b/Console.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Console.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.PostgreSQL/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Console.Data.PostgreSQL/.claude/skills/nano-add-identity/SKILL.md b/Console.Data.PostgreSQL/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Console.Data.PostgreSQL/.claude/skills/nano-add-identity/SKILL.md +++ b/Console.Data.PostgreSQL/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Console.Data.PostgreSQL/.github/copilot-instructions.md b/Console.Data.PostgreSQL/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Console.Data.PostgreSQL/.github/copilot-instructions.md +++ b/Console.Data.PostgreSQL/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Console.Data.PostgreSQL/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Data.PostgreSQL/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Console.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md b/Console.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Console.Data.PostgreSQL/.github/prompts/nano-add-identity.prompt.md b/Console.Data.PostgreSQL/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-add-identity.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Console.Data.PostgreSQL/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Data.PostgreSQL/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Console.Data.PostgreSQL/AGENTS.md b/Console.Data.PostgreSQL/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Console.Data.PostgreSQL/AGENTS.md +++ b/Console.Data.PostgreSQL/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Console.Data.PostgreSQL/Console.Data.PostgreSQL/Data/Mappings/ExampleMapping.cs b/Console.Data.PostgreSQL/Console.Data.PostgreSQL/Data/Mappings/ExampleMapping.cs index 059abbc5..802d275a 100644 --- a/Console.Data.PostgreSQL/Console.Data.PostgreSQL/Data/Mappings/ExampleMapping.cs +++ b/Console.Data.PostgreSQL/Console.Data.PostgreSQL/Data/Mappings/ExampleMapping.cs @@ -11,12 +11,10 @@ namespace Console.Data.PostgreSQL.Data.Mappings; public class ExampleMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Console.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md b/Console.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Console.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.SqLite/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Console.Data.SqLite/.claude/skills/nano-add-identity/SKILL.md b/Console.Data.SqLite/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Console.Data.SqLite/.claude/skills/nano-add-identity/SKILL.md +++ b/Console.Data.SqLite/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Console.Data.SqLite/.github/copilot-instructions.md b/Console.Data.SqLite/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Console.Data.SqLite/.github/copilot-instructions.md +++ b/Console.Data.SqLite/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Console.Data.SqLite/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Data.SqLite/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Console.Data.SqLite/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Console.Data.SqLite/.github/prompts/nano-add-entity.prompt.md b/Console.Data.SqLite/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Console.Data.SqLite/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Console.Data.SqLite/.github/prompts/nano-add-identity.prompt.md b/Console.Data.SqLite/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Console.Data.SqLite/.github/prompts/nano-add-identity.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Console.Data.SqLite/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Data.SqLite/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Console.Data.SqLite/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Console.Data.SqLite/AGENTS.md b/Console.Data.SqLite/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Console.Data.SqLite/AGENTS.md +++ b/Console.Data.SqLite/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Console.Data.SqLite/Console.Data.SqLite/Data/Mappings/ExampleMapping.cs b/Console.Data.SqLite/Console.Data.SqLite/Data/Mappings/ExampleMapping.cs index bbbf1fab..0f2f3066 100644 --- a/Console.Data.SqLite/Console.Data.SqLite/Data/Mappings/ExampleMapping.cs +++ b/Console.Data.SqLite/Console.Data.SqLite/Data/Mappings/ExampleMapping.cs @@ -11,12 +11,10 @@ namespace Console.Data.SqLite.Data.Mappings; public class ExampleMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Console.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md b/Console.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Console.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Data.SqlServer/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Console.Data.SqlServer/.claude/skills/nano-add-identity/SKILL.md b/Console.Data.SqlServer/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Console.Data.SqlServer/.claude/skills/nano-add-identity/SKILL.md +++ b/Console.Data.SqlServer/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Console.Data.SqlServer/.github/copilot-instructions.md b/Console.Data.SqlServer/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Console.Data.SqlServer/.github/copilot-instructions.md +++ b/Console.Data.SqlServer/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Console.Data.SqlServer/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Data.SqlServer/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Console.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md b/Console.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Console.Data.SqlServer/.github/prompts/nano-add-identity.prompt.md b/Console.Data.SqlServer/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-add-identity.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Console.Data.SqlServer/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Data.SqlServer/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Console.Data.SqlServer/AGENTS.md b/Console.Data.SqlServer/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Console.Data.SqlServer/AGENTS.md +++ b/Console.Data.SqlServer/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Console.Data.SqlServer/Console.Data.SqlServer/Data/Mappings/ExampleMapping.cs b/Console.Data.SqlServer/Console.Data.SqlServer/Data/Mappings/ExampleMapping.cs index dba812d8..9154218c 100644 --- a/Console.Data.SqlServer/Console.Data.SqlServer/Data/Mappings/ExampleMapping.cs +++ b/Console.Data.SqlServer/Console.Data.SqlServer/Data/Mappings/ExampleMapping.cs @@ -11,12 +11,10 @@ namespace Console.Data.SqlServer.Data.Mappings; public class ExampleMapping : BaseEntityMapping { /// - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); diff --git a/Console.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md b/Console.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Console.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Console.Eventing.RabbitMq/.claude/skills/nano-add-identity/SKILL.md b/Console.Eventing.RabbitMq/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Console.Eventing.RabbitMq/.claude/skills/nano-add-identity/SKILL.md +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Console.Eventing.RabbitMq/.github/copilot-instructions.md b/Console.Eventing.RabbitMq/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Console.Eventing.RabbitMq/.github/copilot-instructions.md +++ b/Console.Eventing.RabbitMq/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Console.Eventing.RabbitMq/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Eventing.RabbitMq/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Console.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md b/Console.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Console.Eventing.RabbitMq/.github/prompts/nano-add-identity.prompt.md b/Console.Eventing.RabbitMq/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-add-identity.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Console.Eventing.RabbitMq/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Eventing.RabbitMq/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Console.Eventing.RabbitMq/AGENTS.md b/Console.Eventing.RabbitMq/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Console.Eventing.RabbitMq/AGENTS.md +++ b/Console.Eventing.RabbitMq/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Console.ExceptionHandling/.claude/skills/nano-add-entity/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Console.ExceptionHandling/.claude/skills/nano-add-identity/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-identity/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Console.ExceptionHandling/.github/copilot-instructions.md b/Console.ExceptionHandling/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Console.ExceptionHandling/.github/copilot-instructions.md +++ b/Console.ExceptionHandling/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-entity.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-entity.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-identity.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-identity.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-postman-collection.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Console.ExceptionHandling/AGENTS.md b/Console.ExceptionHandling/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Console.ExceptionHandling/AGENTS.md +++ b/Console.ExceptionHandling/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Console.Localization/.claude/skills/nano-add-entity/SKILL.md b/Console.Localization/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Console.Localization/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Console.Localization/.claude/skills/nano-add-identity/SKILL.md b/Console.Localization/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Console.Localization/.claude/skills/nano-add-identity/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Console.Localization/.github/copilot-instructions.md b/Console.Localization/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Console.Localization/.github/copilot-instructions.md +++ b/Console.Localization/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Console.Localization/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Localization/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Console.Localization/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Console.Localization/.github/prompts/nano-add-entity.prompt.md b/Console.Localization/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Console.Localization/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Console.Localization/.github/prompts/nano-add-identity.prompt.md b/Console.Localization/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Console.Localization/.github/prompts/nano-add-identity.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Console.Localization/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Localization/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Console.Localization/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Console.Localization/AGENTS.md b/Console.Localization/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Console.Localization/AGENTS.md +++ b/Console.Localization/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Console.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md b/Console.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Console.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Logging.Log4Net/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Console.Logging.Log4Net/.claude/skills/nano-add-identity/SKILL.md b/Console.Logging.Log4Net/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Console.Logging.Log4Net/.claude/skills/nano-add-identity/SKILL.md +++ b/Console.Logging.Log4Net/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Console.Logging.Log4Net/.github/copilot-instructions.md b/Console.Logging.Log4Net/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Console.Logging.Log4Net/.github/copilot-instructions.md +++ b/Console.Logging.Log4Net/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Console.Logging.Log4Net/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Logging.Log4Net/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Console.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md b/Console.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Console.Logging.Log4Net/.github/prompts/nano-add-identity.prompt.md b/Console.Logging.Log4Net/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-add-identity.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Console.Logging.Log4Net/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Logging.Log4Net/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Console.Logging.Log4Net/AGENTS.md b/Console.Logging.Log4Net/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Console.Logging.Log4Net/AGENTS.md +++ b/Console.Logging.Log4Net/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Console.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md b/Console.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Console.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Logging.Microsoft/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Console.Logging.Microsoft/.claude/skills/nano-add-identity/SKILL.md b/Console.Logging.Microsoft/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Console.Logging.Microsoft/.claude/skills/nano-add-identity/SKILL.md +++ b/Console.Logging.Microsoft/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Console.Logging.Microsoft/.github/copilot-instructions.md b/Console.Logging.Microsoft/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Console.Logging.Microsoft/.github/copilot-instructions.md +++ b/Console.Logging.Microsoft/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Console.Logging.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Logging.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Console.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md b/Console.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Console.Logging.Microsoft/.github/prompts/nano-add-identity.prompt.md b/Console.Logging.Microsoft/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-add-identity.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Console.Logging.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Logging.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Console.Logging.Microsoft/AGENTS.md b/Console.Logging.Microsoft/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Console.Logging.Microsoft/AGENTS.md +++ b/Console.Logging.Microsoft/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Console.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md b/Console.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Console.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Logging.NLog/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Console.Logging.NLog/.claude/skills/nano-add-identity/SKILL.md b/Console.Logging.NLog/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Console.Logging.NLog/.claude/skills/nano-add-identity/SKILL.md +++ b/Console.Logging.NLog/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Console.Logging.NLog/.github/copilot-instructions.md b/Console.Logging.NLog/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Console.Logging.NLog/.github/copilot-instructions.md +++ b/Console.Logging.NLog/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Console.Logging.NLog/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Logging.NLog/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Console.Logging.NLog/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Console.Logging.NLog/.github/prompts/nano-add-entity.prompt.md b/Console.Logging.NLog/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Console.Logging.NLog/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Console.Logging.NLog/.github/prompts/nano-add-identity.prompt.md b/Console.Logging.NLog/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Console.Logging.NLog/.github/prompts/nano-add-identity.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Console.Logging.NLog/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Logging.NLog/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Console.Logging.NLog/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Console.Logging.NLog/AGENTS.md b/Console.Logging.NLog/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Console.Logging.NLog/AGENTS.md +++ b/Console.Logging.NLog/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Console.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md b/Console.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Console.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Logging.Serilog/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Console.Logging.Serilog/.claude/skills/nano-add-identity/SKILL.md b/Console.Logging.Serilog/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Console.Logging.Serilog/.claude/skills/nano-add-identity/SKILL.md +++ b/Console.Logging.Serilog/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Console.Logging.Serilog/.github/copilot-instructions.md b/Console.Logging.Serilog/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Console.Logging.Serilog/.github/copilot-instructions.md +++ b/Console.Logging.Serilog/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Console.Logging.Serilog/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Logging.Serilog/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Console.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md b/Console.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Console.Logging.Serilog/.github/prompts/nano-add-identity.prompt.md b/Console.Logging.Serilog/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-add-identity.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Console.Logging.Serilog/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Logging.Serilog/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Console.Logging.Serilog/AGENTS.md b/Console.Logging.Serilog/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Console.Logging.Serilog/AGENTS.md +++ b/Console.Logging.Serilog/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Console.StartupTasks/.claude/skills/nano-add-entity/SKILL.md b/Console.StartupTasks/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Console.StartupTasks/.claude/skills/nano-add-identity/SKILL.md b/Console.StartupTasks/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-identity/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Console.StartupTasks/.github/copilot-instructions.md b/Console.StartupTasks/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Console.StartupTasks/.github/copilot-instructions.md +++ b/Console.StartupTasks/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Console.StartupTasks/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Console.StartupTasks/.github/prompts/nano-add-entity.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-entity.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Console.StartupTasks/.github/prompts/nano-add-identity.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-identity.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Console.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Console.StartupTasks/AGENTS.md b/Console.StartupTasks/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Console.StartupTasks/AGENTS.md +++ b/Console.StartupTasks/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Console.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md b/Console.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Console.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Storage.Azure/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Console.Storage.Azure/.claude/skills/nano-add-identity/SKILL.md b/Console.Storage.Azure/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Console.Storage.Azure/.claude/skills/nano-add-identity/SKILL.md +++ b/Console.Storage.Azure/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Console.Storage.Azure/.github/copilot-instructions.md b/Console.Storage.Azure/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Console.Storage.Azure/.github/copilot-instructions.md +++ b/Console.Storage.Azure/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Console.Storage.Azure/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Storage.Azure/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Console.Storage.Azure/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Console.Storage.Azure/.github/prompts/nano-add-entity.prompt.md b/Console.Storage.Azure/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Console.Storage.Azure/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Console.Storage.Azure/.github/prompts/nano-add-identity.prompt.md b/Console.Storage.Azure/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Console.Storage.Azure/.github/prompts/nano-add-identity.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Console.Storage.Azure/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Storage.Azure/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Console.Storage.Azure/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Console.Storage.Azure/AGENTS.md b/Console.Storage.Azure/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Console.Storage.Azure/AGENTS.md +++ b/Console.Storage.Azure/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Console.Storage.Local/.claude/skills/nano-add-entity/SKILL.md b/Console.Storage.Local/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Console.Storage.Local/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Storage.Local/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Console.Storage.Local/.claude/skills/nano-add-identity/SKILL.md b/Console.Storage.Local/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Console.Storage.Local/.claude/skills/nano-add-identity/SKILL.md +++ b/Console.Storage.Local/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Console.Storage.Local/.github/copilot-instructions.md b/Console.Storage.Local/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Console.Storage.Local/.github/copilot-instructions.md +++ b/Console.Storage.Local/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Console.Storage.Local/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Storage.Local/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Console.Storage.Local/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Console.Storage.Local/.github/prompts/nano-add-entity.prompt.md b/Console.Storage.Local/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Console.Storage.Local/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Console.Storage.Local/.github/prompts/nano-add-identity.prompt.md b/Console.Storage.Local/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Console.Storage.Local/.github/prompts/nano-add-identity.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Console.Storage.Local/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Storage.Local/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Console.Storage.Local/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Console.Storage.Local/AGENTS.md b/Console.Storage.Local/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Console.Storage.Local/AGENTS.md +++ b/Console.Storage.Local/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Console.Workers/.claude/skills/nano-add-entity/SKILL.md b/Console.Workers/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Console.Workers/.claude/skills/nano-add-entity/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Console.Workers/.claude/skills/nano-add-identity/SKILL.md b/Console.Workers/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Console.Workers/.claude/skills/nano-add-identity/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Console.Workers/.github/copilot-instructions.md b/Console.Workers/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Console.Workers/.github/copilot-instructions.md +++ b/Console.Workers/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Console.Workers/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console.Workers/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Console.Workers/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Console.Workers/.github/prompts/nano-add-entity.prompt.md b/Console.Workers/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Console.Workers/.github/prompts/nano-add-entity.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Console.Workers/.github/prompts/nano-add-identity.prompt.md b/Console.Workers/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Console.Workers/.github/prompts/nano-add-identity.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Console.Workers/.github/prompts/nano-add-postman-collection.prompt.md b/Console.Workers/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Console.Workers/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Console.Workers/AGENTS.md b/Console.Workers/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Console.Workers/AGENTS.md +++ b/Console.Workers/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Console._Blank/.claude/skills/nano-add-entity/SKILL.md b/Console._Blank/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Console._Blank/.claude/skills/nano-add-entity/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Console._Blank/.claude/skills/nano-add-identity/SKILL.md b/Console._Blank/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Console._Blank/.claude/skills/nano-add-identity/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Console._Blank/.github/copilot-instructions.md b/Console._Blank/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Console._Blank/.github/copilot-instructions.md +++ b/Console._Blank/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Console._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md b/Console._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Console._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Console._Blank/.github/prompts/nano-add-entity.prompt.md b/Console._Blank/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Console._Blank/.github/prompts/nano-add-entity.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Console._Blank/.github/prompts/nano-add-identity.prompt.md b/Console._Blank/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Console._Blank/.github/prompts/nano-add-identity.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Console._Blank/.github/prompts/nano-add-postman-collection.prompt.md b/Console._Blank/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Console._Blank/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Console._Blank/AGENTS.md b/Console._Blank/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Console._Blank/AGENTS.md +++ b/Console._Blank/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once. diff --git a/Web._Blank/.claude/skills/nano-add-entity/SKILL.md b/Web._Blank/.claude/skills/nano-add-entity/SKILL.md index 11db2020..2d1237bc 100644 --- a/Web._Blank/.claude/skills/nano-add-entity/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-entity/SKILL.md @@ -161,21 +161,20 @@ the split layout — mappings are EF Core-only, never part of the shared API cli ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration — omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. - **Configure every one of the entity's own properties explicitly — including navigations and collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a @@ -256,7 +255,7 @@ public class Mapping : BaseEntityMapping<> - **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; diff --git a/Web._Blank/.claude/skills/nano-add-identity/SKILL.md b/Web._Blank/.claude/skills/nano-add-identity/SKILL.md index 478586e4..c5a424e4 100644 --- a/Web._Blank/.claude/skills/nano-add-identity/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-identity/SKILL.md @@ -107,9 +107,8 @@ convert, in which case its existing properties carry over as-is; don't ask for t configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing entity** (step 3), convert its existing mapping file the same way — just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from diff --git a/Web._Blank/.github/copilot-instructions.md b/Web._Blank/.github/copilot-instructions.md index 39722a8f..f0659ab8 100644 --- a/Web._Blank/.github/copilot-instructions.md +++ b/Web._Blank/.github/copilot-instructions.md @@ -1,8 +1,8 @@ -# Copilot instructions — Nano Framework +# Copilot instructions - Nano Framework This repository (and any application built on the `NanoCore`/`Nano.*` NuGet packages) uses the **Nano framework** for API/Web/Console applications. Before making changes, check the repo root -for an `AGENTS.md` — it is the authoritative implementation reference for Nano and documents the +for an `AGENTS.md`: it is the authoritative implementation reference for Nano and documents the exact base classes, configuration, and gotchas for that specific solution. These instructions are a short always-on summary; `AGENTS.md` takes precedence on any conflict. @@ -10,30 +10,30 @@ a short always-on summary; `AGENTS.md` takes precedence on any conflict. A Nano app named `{name}` conventionally looks like: -- `{name}/` — the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, +- `{name}/`: the app project (`Program.cs`, `Controllers/`, `Data/` for `DbContext`+`Mappings/`, `appsettings*.json`). -- `{name}.Models/` — a **separate sibling project** (not nested) holding entity models, - `Criterias/` (query criteria), and `Api/` (typed API client) — only present in a "split layout"; +- `{name}.Models/`: a **separate sibling project** (not nested) holding entity models, + `Criterias/` (query criteria), and `Api/` (typed API client), only present in a "split layout"; many smaller apps use a single-project layout with everything in `{name}/` instead. -- `.tests/Tests.{name}/` — test project. -- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile` — local +- `.tests/Tests.{name}/`: test project. +- `.docker/`, `.kubernetes/`, `.github/workflows/build-and-deploy.yml`, root `Dockerfile`: local orchestration, deployment, and CI/CD. Folder names (`Controllers/`, `Data/`, `Criterias/`, `Api/`) are convention, not a framework -requirement — Nano discovers controllers, mappings, and data providers by type via reflection, not +requirement: Nano discovers controllers, mappings, and data providers by type via reflection, not by location. ## Conventions to follow - **Identity type**: default to `Guid` (`BaseEntity` = `BaseEntity`) unless the project already consistently uses another `TIdentity` everywhere (entities, mappings, repository, - controllers, API client) — it is a cross-cutting choice, never a per-entity one. -- **Entity mappings** must call `base.Configure(builder)` before any custom configuration — - omitting it silently breaks inherited behavior (soft delete, audit, etc.). + controllers, API client): it is a cross-cutting choice, never a per-entity one. +- **Entity mappings** override `ConfigureEntity`, never `Configure` (it is not virtual). Nano + applies its inherited behavior (soft delete, audit, etc.) first, so there is no `base` call to make. - **Controller naming is load-bearing**: `` + literal `s` + `Controller` (naive - pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals) — Nano + pluralization, e.g. `Country` → `CountrysController`, not "correct" English plurals): Nano derives the route from the class name. -- No manual registration is needed for mappings, controllers, startup tasks, or API clients — +- No manual registration is needed for mappings, controllers, startup tasks, or API clients: Nano discovers them by type/assembly scanning. Don't add DI registration calls for these unless a project's existing code clearly does otherwise. - Match existing conventions in the project (nullable-reference style, split vs single-project diff --git a/Web._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md b/Web._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md index ce8cb9db..7bf05ff9 100644 --- a/Web._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -16,37 +16,37 @@ controller's own namespace). **Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of contract another application will call, so scaffolding it also means scaffolding the client-side -half of that same contract - one coherent task, not two skills chained together. **Step 2** below +half of that same contract — one coherent task, not two skills chained together. **Step 2** below determines which applies; read only the matching path once it's decided. ⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (this solution's own project template for one is `nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager -TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +TLS layer in front of a cluster — an unrelated, infrastructure-level concept. Don't reuse that word for this application-level role, in code, comments, or conversation with the user. --- -## Step 1 - Confirm a custom endpoint is actually needed +## Step 1 — Confirm a custom endpoint is actually needed -Per AGENTS.md's `## Core Principle - Built-In Before Custom`: a custom endpoint is only warranted +Per AGENTS.md's `## Core Principle — Built-In Before Custom`: a custom endpoint is only warranted once the generic `.Entity` surface + `[Include]`-driven eager loading + query criteria is confirmed -insufficient - not just "less convenient." Walk through this before scaffolding anything: +insufficient — not just "less convenient." Walk through this before scaffolding anything: - **Can the desired response be expressed as the target entity plus some of its navigation properties, at some depth?** If yes, this is very likely not a custom endpoint at all: tag the needed navigations `[Include]` on the entity (in the owning service's `.Models` project) and have - the caller pass `includeDepth` through the generic `.Entity` call - `0` for a bare entity, higher + the caller pass `includeDepth` through the generic `.Entity` call — `0` for a bare entity, higher to reach further navigations. See AGENTS.md's Include Annotation section for the exact mechanics. - **Two real limits of that mechanism, either of which can still justify going custom even when the shape looks nav-expressible:** - - **No selective `$expand`.** `includeDepth` only dials recursion depth - it can't pick which + - **No selective `$expand`.** `includeDepth` only dials recursion depth — it can't pick which tagged navigations come back at that depth. If the response needs entity+NavA at depth 2 but never NavB even though NavB sits at the same depth, `[Include]` can't express that distinction; a custom endpoint can. - **`[Include]` is global to the entity, not scoped to one caller.** Tagging a navigation makes - it eager-loadable for *every* consumer of that entity's generic endpoints - other internal - services, other Public APIs - not just the one that prompted the change. If a navigation + it eager-loadable for *every* consumer of that entity's generic endpoints — other internal + services, other Public APIs — not just the one that prompted the change. If a navigation genuinely shouldn't be reachable by every other consumer (sensitive data, a payload-size concern for high-traffic internal callers), that's a real reason to keep a narrowly-scoped custom endpoint instead of tagging it. @@ -54,7 +54,7 @@ insufficient - not just "less convenient." Walk through this before scaffolding entity, responses composed from more than one unrelated entity graph, or actual business logic beyond read/write. A representative case: an action that has to validate something (e.g. an email domain against a set of allowed domains) and then perform a multi-entity write as one - atomic operation, where the write can't happen at all until the validation passes - neither step + atomic operation, where the write can't happen at all until the validation passes — neither step is expressible as a single generic `.Entity` call, and splitting them into two separate generic calls from the caller would let the write happen without the validation ever running. This is the right call for a custom endpoint, not a sign to keep looking for a generic-composition way @@ -62,13 +62,13 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **A combined result built from several generic calls belongs on the owning service.** A union across two entity types (e.g. "roles owned by this tenant, plus roles reachable via its subscription plan") is not a fetch followed by an action, it is logic, so build it as a real custom endpoint on the - *target* service from the start - one round trip, one place the logic lives, instead of the same + *target* service from the start — one round trip, one place the logic lives, instead of the same non-trivial join reimplemented at every caller. The same two calls showing up in a second action is a clear sign it was missed. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" - endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + endpoint often isn't pulling its weight — the caller can fetch or already has the list and pick the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id rather than filter client-side, can still justify keeping both), but don't scaffold the single-item version reflexively just because a list version exists; ask @@ -76,40 +76,40 @@ insufficient - not just "less convenient." Walk through this before scaffolding - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a - sibling entity's existence makes the parent immutable) - and it should apply no matter which - caller hits the generic route, not just one Public API that remembers to compose it - that's a + sibling entity's existence makes the parent immutable) — and it should apply no matter which + caller hits the generic route, not just one Public API that remembers to compose it — that's a case for **overriding the generic CRUD action** on the owning entity's own controller, not adding a separate custom endpoint. See **Internal service path § Overriding a generic CRUD action instead of a new endpoint** below before scaffolding a new route for this. - **Before designing a custom action (or a composition) around a delete or an update, check what the database relationship already does for you.** A required (non-optional) EF Core relationship - with no explicit `.OnDelete(...)` override defaults to `Cascade` - deleting the parent already + with no explicit `.OnDelete(...)` override defaults to `Cascade` — deleting the parent already removes the dependent row(s) at the database level, so an explicit second delete call for that child is redundant, not just harmless. This does **not** apply if the parent is soft-deletable (`IEntitySoftDeletable`): a soft delete is a plain `UPDATE` setting `IsDeleted`, not a real SQL `DELETE`, so no FK cascade fires and any dependent cleanup has to stay explicit. The reverse gotcha applies to edits: the generic `Edit`/`EditAndGet` actions only copy scalar properties onto - the tracked entity (`SetValues`-style) - reassigning a collection navigation and calling generic + the tracked entity (`SetValues`-style) — reassigning a collection navigation and calling generic Edit does **not** add/remove the underlying rows, so an update that needs to reconcile a child collection still needs its own explicit add/remove calls (composed at the Public API, or inside - an overridden action - see below). Check both directions before adding calls a real cascade + an overridden action — see below). Check both directions before adding calls a real cascade already makes unnecessary, or assuming a collection reassignment does something it doesn't. If the generic surface (with appropriate `[Include]` tagging and `includeDepth`) already covers -this, say so and point the user at that instead of scaffolding something redundant - don't build a +this, say so and point the user at that instead of scaffolding something redundant — don't build a custom action just because it was asked for without checking first. Note that adding a new `[Include]` tag to an already-consumed entity is itself a change to that entity's existing generic -surface, not a free side-effect - say so rather than tagging it silently. +surface, not a free side-effect — say so rather than tagging it silently. -## Step 2 - Public API or internal service? +## Step 2 — Public API or internal service? -Not always obvious from the request alone - ask if unclear, don't default to one. Getting this +Not always obvious from the request alone — ask if unclear, don't default to one. Getting this wrong means the wrong constructor, the wrong dependency, and a DTO shape aimed at the wrong layer: -- **Public API controller** - the action composes one or more injected Api Clients +- **Public API controller** — the action composes one or more injected Api Clients (`.Entity`/`.Auth`/`.Audit`/`.Identity` calls, or an existing custom client method) into one response; it has no `IRepository` of its own. Go to **Public API path** below. -- **Internal service controller** - the action implements logic directly against this app's own +- **Internal service controller** — the action implements logic directly against this app's own `IRepository`/`IEventing`, either as a custom method on an existing entity controller (`BaseEntityController<...>` subclass) or a bare `BaseController` action with no entity backing it at all. Go to **Internal service path** below. @@ -122,61 +122,61 @@ belongs in the internal service, as one custom action behind its Api Client: com deciding something from which other rows exist, or building a combined result or flags from several calls. If a Public API action would need more than fetch-then-act or check-then-act, scaffold the internal service endpoint instead and have the Public API call just that. Combining calls to -different services is the Public API's job. Controllers never share logic with each other - a +different services is the Public API's job. Controllers never share logic with each other — a service puts shared logic in a repository extension method (an `IRepository` extension in the app's `Extensions/` folder). -## Step 3 - Pin down shape and conventions +## Step 3 — Pin down shape and conventions - **Endpoint shape.** HTTP verb, route segment, input shape (parameters), and response shape. Ask - for whatever isn't already given - don't invent fields, routes, or status codes that weren't + for whatever isn't already given — don't invent fields, routes, or status codes that weren't asked for or that don't match an existing sibling action's pattern in the same controller/project. - **Naming and location conventions.** Skim an existing custom action in the same controller (or a sibling controller in the same project) for its `Requests/`/`Responses/` folder layout, - doc-comment style, and `[ProducesResponseType]` set, and match it - this solution already has an + doc-comment style, and `[ProducesResponseType]` set, and match it — this solution already has an established shape for this, don't invent a new one. --- ## Shared DTO conventions -Both paths below build request/response DTOs the same way - read this once, apply it wherever a +Both paths below build request/response DTOs the same way — read this once, apply it wherever a DTO comes up in either path: -- **Only include properties the endpoint actually needs** - no speculative fields, and (for a +- **Only include properties the endpoint actually needs** — no speculative fields, and (for a request) only what the *caller* should be able to set, never fields that represent internal/server-assigned state (an entity's `Id`, audit timestamps, computed status, etc.), even if the controller action happens to build an entity from the request afterward. - **Match validation attributes to what the underlying entity/write actually needs, not just `[Required]`.** `[MaxLength]` on a string that maps to a length-constrained column, `[Range]` on - a bounded number, etc. - mirror whatever the entity's own mapping/property already enforces, so a + a bounded number, etc. — mirror whatever the entity's own mapping/property already enforces, so a bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably - already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + already has a `RoleResponse` (or equivalent) somewhere in the same project — reuse it rather than defining a near-duplicate. This applies across paths too: if an internal-service change makes a Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, not to keep both. -- **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to +- **Default to returning the entity (or collection of entities) directly — reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with - the right navigations eager-loaded. Return that type directly - - `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + specific for the entity itself — the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly — + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` — not wrapped in a `Response` that just repeats the same properties. Building a custom Response DTO is valid, but treat it as the *last resort*: reach for it when the shape genuinely can't come from the entity plus - `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any - Subscription," derived at read time rather than persisted - or a flattened projection across more - than one unrelated entity graph) - not by default, and not just because it's the response of a + `[Include]` (computed/aggregated fields not stored anywhere — e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted — or a flattened projection across more + than one unrelated entity graph) — not by default, and not just because it's the response of a custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the - property itself - and this applies independently at every level of the graph. A response built by + property itself — and this applies independently at every level of the graph. A response built by walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those navigation properties, not just the first one; skipping a middle link means that step silently comes back empty even though the ends are tagged correctly. Trace the exact path the response @@ -187,51 +187,51 @@ DTO comes up in either path: ## Public API path -The controller composes calls that already exist elsewhere - this path never defines a new Api +The controller composes calls that already exist elsewhere — this path never defines a new Api Client method of its own. ### Does the backing call already exist? Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an -existing custom method - including a custom method on the *target* service's own controller that +existing custom method — including a custom method on the *target* service's own controller that already computes the exact union/aggregate this action needs (e.g. an existing "get all roles available to a tenant" internal-service action), rather than re-deriving the same result here via several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user -whether to create it now**. That method's controller action lives on the *target* service - a +whether to create it now**. That method's controller action lives on the *target* service — a different application than this Public API. If the target's Api Client class doesn't exist at all yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal service path*, run against the target application, not this one. Don't invoke either automatically, and don't scaffold this Public API action against a method that doesn't exist yet as if it already -does - proceed here only once the user has confirmed whether/how that gets created elsewhere. +does — proceed here only once the user has confirmed whether/how that gets created elsewhere. -**Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client +**Don't wrap a plain generic call — or a plain generic *composition* — in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or -extra logic of its own, call it directly from this controller action - don't add a method to the +extra logic of its own, call it directly from this controller action — don't add a method to the target's Api Client class that does nothing but forward to the generic method. This isn't limited to a single call: a get-then-create/edit/delete pattern (look something up, then mutate based on what came back) is still just generic composition, not custom logic, and reads perfectly fine as -2-3 calls directly in the controller action - it doesn't earn a wrapper method just because it's +2-3 calls directly in the controller action — it doesn't earn a wrapper method just because it's more than one line. A custom Api Client method should only exist when it's paired with a controller action doing something the generic surface genuinely can't (the Internal service path -below, e.g. cross-entity validation gating a write) - a bare composition of generic calls, wrapped +below, e.g. cross-entity validation gating a write) — a bare composition of generic calls, wrapped or not, just hides what's actually happening for no benefit. **Don't add a redundant existence pre-check in front of a built-in `.Identity` action.** Activate/ -Deactivate/etc. already handle a nonexistent id themselves (404/no-op) - a `QueryFirstAsync` purely +Deactivate/etc. already handle a nonexistent id themselves (404/no-op) — a `QueryFirstAsync` purely to confirm the id exists before calling one is duplicated work the service already does. Only look something up first if the action needs data the built-in call doesn't already return, or needs to -enforce an authorization/scoping check the built-in call doesn't perform itself - and if you skip +enforce an authorization/scoping check the built-in call doesn't perform itself — and if you skip the lookup specifically to avoid that redundancy, say plainly in a comment whether that also drops a scoping check (e.g. tenant ownership) the lookup used to provide, so it's a visible trade-off rather than a silent one. ### Request DTO (if the action takes parameters) -Location: `Requests//Request.cs` in the Public API's own app project - **this is a +Location: `Requests//Request.cs` in the Public API's own app project — **this is a Public-API-facing DTO, not an Api Client `BaseRequest`**; don't confuse the two even though an Api Client call happens inside the same action. @@ -258,7 +258,7 @@ belongs on this class. ### Response DTO (if the action returns a body) Location: `Responses//Response.cs`, same project. A plain POCO (no base type -required) shaped to exactly what the caller needs - not a raw pass-through of an internal entity +required) shaped to exactly what the caller needs — not a raw pass-through of an internal entity unless that's genuinely what the sibling conventions in this project do. See **Shared DTO conventions** above for the entity-vs-DTO default and the nested-`[Include]` requirement. @@ -292,7 +292,7 @@ public virtual async Task Async([FromBody][Required] } ``` -- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic - +- Only include the `[ProducesResponseType]`s that are actually reachable by this action's logic — match what sibling actions in the same controller declare, don't pad the list. - **Only use the `...AndGet` Api Client calls when the returned entity is actually used.** `.Entity.CreateAndGetAsync`/`EditAndGetAsync` (with `CreateAndGetRequest`/`EditAndGetRequest`) make the @@ -322,34 +322,34 @@ public virtual async Task Async([FromBody][Required] collection - also fine for an `ICollection` navigation being persisted) and use `.ToList()` only when a `List` is truly needed (`Add`/`AddRange`, an API that takes a `List`). Likewise no `?? []` on a collection an Api Client call returns. -- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) - and if it +- `[AllowAnonymous]` only if this runs before a JWT exists (e.g. part of a login flow) — and if it calls another application's endpoint in that state, that target endpoint must itself be `[AllowAnonymous]`; note that requirement in a comment. - **Caller-context claims (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this action needs a piece of the caller's identity further downstream, **read it here** from this app's own already-validated JWT/`HttpContext` and pass it explicitly as a field - on the outgoing custom request - don't rely on the target service re-extracting the same claim + on the outgoing custom request — don't rely on the target service re-extracting the same claim from the JWT Nano forwards alongside the call. --- ## Internal service path -This action **is** a new piece of contract another application will call - scaffolding it means +This action **is** a new piece of contract another application will call — scaffolding it means scaffolding both halves together: the controller action, and the paired Api Client custom request/method that lets other applications actually call it. ### Does this app's own Api Client class exist yet? Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If -none exists, create the bare class inline as part of this same change - it's boilerplate with no +none exists, create the bare class inline as part of this same change — it's boilerplate with no decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and chain into a separate skill. ### Shared body model If the action takes parameters, define the payload **once**, as a plain model class in -`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) - this is the class **both** +`{ThisApp}.Models` (conventionally `Api/Requests/Models/.cs`) — this is the class **both** the controller's `[FromBody]` parameter **and** the Api Client request's `[Body]` property bind to, not two separate DTOs kept in sync by hand: @@ -362,7 +362,7 @@ public class ``` See **Shared DTO conventions** above for what belongs on this class. If the action returns a body, -prefer returning the target entity/collection directly (same section) - a +prefer returning the target entity/collection directly (same section) — a `Responses//Response.cs` POCO is the last resort, for when the shape genuinely can't come from the entity itself. @@ -387,24 +387,24 @@ public class MyActionRequest : BaseRequest ``` **Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this works fine whenever a custom request's response +the pluralized `TResponse` type name — this works fine whenever a custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to an existing entity controller that still returns that entity). Set `this.Controller` explicitly in the constructor only in the two cases where inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer +- **No response at all** (`InvokeAsync`, no `TResponse`) — there's no type to infer from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is +- **The route doesn't align with `TResponse`'s pluralized name** — either because `TResponse` is a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the same shape as the example above. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` -below - per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests +below — per AGENTS.md, nothing else keeps the two sides in sync, and Nano's own built-in requests avoid drift exactly this way. Add the corresponding method to this app's own Api Client class: @@ -422,20 +422,20 @@ public virtual Task MyActionAsync(MyAction model, CancellationToken cancellation One method per custom request, calling `this.InvokeAsync(request, cancellationToken)` (no response) or `this.InvokeAsync(request, cancellationToken)` (typed response). Give the method and its doc comment the same one-liner-summary treatment as the controller action -- name what it does and, if it exists only because the generic surface couldn't express it, why. +— name what it does and, if it exists only because the generic surface couldn't express it, why. **If the caller needs to tell "not found" apart from "found but empty," keep the method's return -type nullable and let a 404 come back as `null` - don't `?? []` it away.** Per AGENTS.md's Api -Clients gotchas, a non-success response never throws for a plain 404 - it returns `null` for +type nullable and let a 404 come back as `null` — don't `?? []` it away.** Per AGENTS.md's Api +Clients gotchas, a non-success response never throws for a plain 404 — it returns `null` for `TResponse`, which for a collection response means `null` (not-found) is already distinguishable from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the -client method's `null` into `[]` "for convenience" - that throws away the exact distinction the +client method's `null` into `[]` "for convenience" — that throws away the exact distinction the caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not -`MyActionAsync(string propertyOne, int propertyTwo, ...)` - the whole point of the shared body +`MyActionAsync(string propertyOne, int propertyTwo, ...)` — the whole point of the shared body model (previous section) is that it *is* the contract's shape; re-exploding it into scalar parameters here just to reconstruct the same object one line later is pointless indirection, and it makes the client method's signature drift from the model instead of just being it. Only @@ -446,12 +446,12 @@ belong as their own parameter alongside the model. **Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an explicit property on the shared body model, populated by the calling application from its own -JWT - don't design this request to assume this controller will re-derive it from the forwarded +JWT — don't design this request to assume this controller will re-derive it from the forwarded token instead. Note in the doc comment which claim the caller is expected to supply and why. **Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. during another app's own login flow), note in the request's doc comment that the controller -action must be `[AllowAnonymous]`, and add that attribute below - the client side can't enforce +action must be `[AllowAnonymous]`, and add that attribute below — the client side can't enforce it, only document the expectation. ### Controller action @@ -482,11 +482,11 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc ``` - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate - entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow + entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet — follow `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic - controller as part of creating it here - this action doesn't replace or narrow that entitlement. + controller as part of creating it here — this action doesn't replace or narrow that entitlement. - **Return what the caller needs.** An action that changes something returns the data the caller will show or use next (for example the finished overview), so the Public API never makes a second call just to fetch it after the action. This is best practice, not an absolute rule. @@ -496,7 +496,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc referencing the same parameter again inside a method captures it a second time and is a compile error (CS9107 - "captured into the state of the enclosing type and its value is also passed to the base constructor"). The base class exposes `this.Repository`/`this.Eventing` properties for - exactly this reason - use those instead. + exactly this reason — use those instead. - Only include the `[ProducesResponseType]`s actually reachable by this action's logic. - Same collection rule as the Public API path: don't `.ToList()`/`?? []` a repository or Api Client result you only enumerate once; materialize only when required, preferring `.ToArray()` and `.ToList()` only when @@ -511,47 +511,47 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - **Throw, don't bare-return, for error responses that will cross an Api Client boundary.** A plain `this.BadRequest()`/`this.NotFound()` `IActionResult` has no `ProblemDetails` body. Per AGENTS.md's Api Clients Gotchas and Error Handling sections: a `404` always surfaces as `null` to - the caller regardless of body, so bare `this.NotFound()` is fine - but any other non-2xx with no + the caller regardless of body, so bare `this.NotFound()` is fine — but any other non-2xx with no parseable `ProblemDetails` body becomes an `ApiClientException` the calling Public API's own middleware doesn't recognize, and it falls back to a **generic 500** for whoever called the Public API, silently losing the real 400. Throw `new BadRequestException("")` (`Nano.App.Exceptions`) (always with a short, human-readable message, never the parameterless form) - instead - the centralized exception-handling middleware turns it into a proper `ProblemDetails` + instead — the centralized exception-handling middleware turns it into a proper `ProblemDetails` 400 that an Api Client parses as `ProblemDetailsException` (any status), which the calling Public API's own middleware then correctly re-surfaces with the same status code. Same idea for a not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. -- **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it +- **Don't narrow an entity's controller tier to dodge a route collision with this action — flag it instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change because a custom action sits alongside it. If this action's route+verb is identical to a route the generic tier also exposes, that's a genuine defect in the request-side contract (one of the - two routes needs to change) - add the action anyway, with a prominent comment naming exactly + two routes needs to change) — add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. -- **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. +- **Check built-in routes on narrower base classes too, not just the generic CRUD table** — e.g. `BaseEntityUserController`'s identity actions (AGENTS.md's Identity user controller table: `{id}/activate`, `{id}/deactivate`, etc.). An action whose route matches one of those collides - exactly the same way a generic CRUD route would - flag it the same way. + exactly the same way a generic CRUD route would — flag it the same way. - **The one exception: a deliberate override, not a collision.** Every generic CRUD action is `virtual` specifically so a subclass can extend it. If what's actually needed is "do what the - base action already does, plus a little extra" - e.g. create the entity, then also publish a - custom event - the correct approach is to **override the base method** (call the base + base action already does, plus a little extra" — e.g. create the entity, then also publish a + custom event — the correct approach is to **override the base method** (call the base implementation, or reproduce its persistence step, then add the extra behavior) on the *same* route, not scaffold a separate custom action that happens to reuse it. An override isn't a - collision at all - same method, same route, extended behavior - so there's nothing to flag. + collision at all — same method, same route, extended behavior — so there's nothing to flag. Reach for this only when the operation genuinely *is* the base behavior plus a bit more; if it's doing something meaningfully different at that route, that's a real collision per the rule - above, not an override candidate. This stays the exception, not the default - most custom + above, not an override candidate. This stays the exception, not the default — most custom actions should still avoid the base routes entirely; don't reach for an override as a shortcut to reuse a route a distinct operation shouldn't share. See the fuller treatment of this pattern - right below - it's common enough to deserve its own walkthrough, not just a one-line exception. + right below — it's common enough to deserve its own walkthrough, not just a one-line exception. #### Overriding a generic CRUD action instead of a new endpoint The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which -caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, +caller/route triggers it" — validation before a create/edit, a linked-entity side-effect after one, a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop -being addable, not just removable, once any Subscription references it) - override the relevant +being addable, not just removable, once any Subscription references it) — override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. @@ -560,53 +560,53 @@ consumer, not just the one Public API that remembered to compose it. caller happens to use.** `BaseEntityController`'s full CRUD route table has several single-entity variants per verb: `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` for create, `EditAsync`/`EditAndGetAsync` for edit, `DeleteAsync`/`DeleteManyAsync` for delete (plus bulk/ - query-based variants - decide per case whether those are reachable/relevant enough to matter). + query-based variants — decide per case whether those are reachable/relevant enough to matter). If the invariant genuinely must always hold, override all of the single-entity variants a caller could plausibly reach; overriding only the one your current Public API calls leaves the same gap a new custom endpoint would have needed to close anyway, just via a different route. - **A new entity's `Id` is already assigned client-side, before persistence.** `BaseEntity`'s - constructor sets `this.Id = Guid.NewGuid()` - so inside a `CreateAsync`/`CreateAndGetAsync`/ + constructor sets `this.Id = Guid.NewGuid()` — so inside a `CreateAsync`/`CreateAndGetAsync`/ `CreateOrGetAsync` override, `entity.Id` is already the real, final id immediately, even before calling `base.CreateAsync(...)`. Use it directly for any follow-up work (e.g. creating a linking row) instead of trying to extract an id back out of the base call's `IActionResult`. -- **The override's signature is fixed by the base method - there's no room to thread extra +- **The override's signature is fixed by the base method — there's no room to thread extra caller-context through it.** `EditAsync(TEntity entity, CancellationToken)`/ `DeleteAsync(Guid id, CancellationToken)` can't gain an extra `tenantId` parameter the way a bespoke custom action could. If per this solution's convention a downstream service doesn't parse the caller's JWT itself (tenant/caller scoping is resolved once at the Public API and - passed down explicitly - see AGENTS.md's Authentication forwarding caveat), then a + passed down explicitly — see AGENTS.md's Authentication forwarding caveat), then a generic-action override can only enforce invariants derivable from the entity/data itself - (permission-subset validation, reference-count guards, linking rows) - it can't perform + (permission-subset validation, reference-count guards, linking rows) — it can't perform tenant-ownership authorization. The Public API still needs its own ownership pre-check (e.g. a scoped `QueryFirst`) before calling the generic write; the override and the Public API check are complementary, not either-or. - **A reference-count/existence guard can gate a create just as validly as a delete.** Don't assume - this pattern only protects against removing something still in use - "reject adding a child row + this pattern only protects against removing something still in use — "reject adding a child row once a sibling entity's existence makes the parent immutable" is the same shape of check (`CountAsync`/`QueryCountAsync` against the related entity, `throw new BadRequestException("")` if it's non-zero), just applied to `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` instead of `DeleteAsync`. If an entity has any notion of "locked" or "immutable" derived from another entity's existence, check both directions before assuming only deletes need guarding. - **Reconciling a collection navigation is still an explicit step inside the override.** The same - "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged - an + "generic Edit only copies scalars" gotcha from **Step 1** applies here unchanged — an `EditAsync`/`EditAndGetAsync` override that needs to add/remove related rows (not just update scalar properties) does so via its own `this.Repository.AddAsync`/`DeleteAsync` calls before or after calling `base.EditAsync(...)`, the same as a Public-API-side composition would have needed to. Overriding moves *where* this logic lives, not whether it's still needed. - **Duplicate the validation across each overridden variant rather than extracting a shared private helper**, if that's this project's established preference for controllers (confirm against - existing sibling controllers/AGENTS.md conventions before assuming) - expect the same block + existing sibling controllers/AGENTS.md conventions before assuming) — expect the same block repeated in `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync` etc. rather than factored out. - Still throw `BadRequestException`/`NotFoundException` for invariant violations inside these - overrides, per the bullet above - the same Api Client propagation gotcha applies whether the + overrides, per the bullet above — the same Api Client propagation gotcha applies whether the error comes from a bespoke custom action or an overridden generic one. - **If this action's route collides with another custom action's route** (same controller, same route+verb): a genuine defect in the request-side contract, not something to silently rename or merge. Scaffold both anyway, with a prominent comment on each naming the other action it - collides with - flag it for the user to resolve rather than guessing. -- **Caller-context claims** - mirror of the request-side note above: read the caller's claims + collides with — flag it for the user to resolve rather than guessing. +- **Caller-context claims** — mirror of the request-side note above: read the caller's claims from this app's own JWT/`HttpContext` if this action needs them for something *further* - downstream (e.g. calling yet another service) - this note is about what the *caller* already + downstream (e.g. calling yet another service) — this note is about what the *caller* already supplied explicitly on the request, which is the normal case for an internal-service action's own use of caller context. @@ -617,28 +617,29 @@ consumer, not just the one Public API that remembered to compose it. If the application already has a Postman collection, every endpoint this skill adds must also land in it: 1. Look for `Postman_.json` in the application's own folder (next to its `README.md`). -2. **If it exists**, add the new endpoint's request to it - inside the folder for its controller, in the - position matching the controller's own action order - with the correct verb, route (the same route +2. **If it exists**, add the new endpoint's request to it — inside the folder for its controller, in the + position matching the controller's own action order — with the correct verb, route (the same route constant the controller uses), request body/query, and a `description` noting anything a tester needs to know (`[AllowAnonymous]`, business-rule guards, side effects). Follow the conventions of the `nano-add-postman-collection` skill (base URL scheme, variable chaining, `bearer` auth). Edit only the new request; don't regenerate or reorder the rest of the file. -3. **If it doesn't exist, do nothing Postman-related** - don't create a collection. The endpoint is picked up +3. **If it doesn't exist, do nothing Postman-related** — don't create a collection. The endpoint is picked up when the user later generates the collection with `nano-add-postman-collection`. -4. If the file was edited, remind the user to **Replace**-import it in Postman - writing the file changes +4. If the file was edited, remind the user to **Replace**-import it in Postman — writing the file changes nothing in Postman by itself. --- + ## After generating - Show the user every file touched/created, grouped by concern (Request/Response DTOs, controller - action, and - for the internal-service path - the shared body model, the Api Client request, + action, and — for the internal-service path — the shared body model, the Api Client request, and the Api Client method) and which project each lives in. - **Internal service path**: state plainly that this scaffolds the contract, not the business - logic - the controller action's body is a stub unless the user asked for the real + logic — the controller action's body is a stub unless the user asked for the real implementation too. - **Public API path**: if a missing custom Api Client method was surfaced and the user hasn't - decided on it yet, that's the natural stopping point - don't scaffold the controller action + decided on it yet, that's the natural stopping point — don't scaffold the controller action against a call that doesn't exist, and don't guess at its shape. -- If step 1 found the generic surface already covers this, that's the whole response - explain +- If step 1 found the generic surface already covers this, that's the whole response — explain what already does the job instead of generating anything. diff --git a/Web._Blank/.github/prompts/nano-add-entity.prompt.md b/Web._Blank/.github/prompts/nano-add-entity.prompt.md index 53f98eb1..589971d8 100644 --- a/Web._Blank/.github/prompts/nano-add-entity.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-entity.prompt.md @@ -7,8 +7,8 @@ description: Scaffold a new Nano.Library entity - data model and EF Core mapping Generates the files Nano needs for a new entity: data model and EF Core mapping always; query criteria and a CRUD controller too, unless the target is a Console application (Console apps -have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read -`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +have no HTTP surface, so there's nothing for either of those to serve — see step 3 below). Read +`AGENTS.md` in the target repo root first if present — it documents the exact base classes and gotchas for that specific solution; this skill assumes the general Nano.Library conventions and defers to a project's own AGENTS.md on any conflict. @@ -17,19 +17,19 @@ defers to a project's own AGENTS.md on any conflict. 1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches - it to a registered context - with no Data provider, the generated files would be dead code + it to a registered context — with no Data provider, the generated files would be dead code with nothing to persist them. If none is registered, stop and tell the user a Data provider needs to be added first; don't generate the entity anyway "for later." -2. **Entity name and properties.** Ask the user if not already given in the request - need +2. **Entity name and properties.** Ask the user if not already given in the request — need at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar properties (name + type). Don't invent business fields that weren't asked for. 3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication`. - **API or Web**: generate all four files below. - - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + - **Console**: generate only File 1 (data model) and File 2 (mapping) — skip Files 3 and 4 entirely (query criteria and controllers are API-request concepts; a Console app has nothing to route them to). Confirm this with the user only if they explicitly asked for a - controller or query criteria on a Console app - otherwise just skip silently; a Console + controller or query criteria on a Console app — otherwise just skip silently; a Console app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never `Controllers/`/`Criterias/`. 4. **Project layout.** Look for a `.Models` project alongside the main app project @@ -41,48 +41,48 @@ defers to a project's own AGENTS.md on any conflict. 5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a + non-`Guid` identity unless the project already uses one consistently — it's a cross-cutting decision (affects the entity, mapping, controller, repository calls, and API client), not something to add on a whim for one entity. 6. **Existing conventions.** Skim one existing entity/mapping (and controller, if applicable) triplet in the project (if any exist) for property style, nullable-reference usage, and namespace layout, and match it. -7. **Every entity gets a generic controller - full stop, independent of whatever else exists for +7. **Every entity gets a generic controller — full stop, independent of whatever else exists for it.** This is not conditional on a query criteria class already existing, and **not conditional - on whether an Api Client happens to reference the entity** - that's a separate concern this + on whether an Api Client happens to reference the entity** — that's a separate concern this skill doesn't judge. When retrofitting controllers onto entities that already exist: for each entity with no controller yet, generate the query criteria class first if one doesn't already - exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + exist (File 3), then the controller against it (File 4) — every entity, not just the ones an Api Client happens to call out. **If a controller already exists for an entity, don't recreate - it** - move on to the next entity. + it** — move on to the next entity. - This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + This skill scaffolds the generic substrate only — it doesn't search for or reason about custom Api Client requests that might target this entity's controller. Whether a pre-existing custom request already points here (only possible when retrofitting a controller onto an entity that - already existed) - and, if so, how its stub action gets scaffolded, named, and checked for - route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + already existed) — and, if so, how its stub action gets scaffolded, named, and checked for + route collisions — is `nano-add-custom-endpoint`'s job, including creating the controller itself inline if this skill hasn't been run yet. That skill already owns controller-resolution, route-constant conventions, and collision/override handling; duplicating any of that judgment here would just be two places for the same rule to drift apart. 8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) - Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + Ask if it isn't already clear from the request — this changes both the CRUD tier (File 1/File 4) and, for `[Subscribe]`, the entity's actual shape: - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` with the property paths other applications are allowed to replicate. Only add paths the - request actually asked to expose - don't publish every scalar property by default. + request actually asked to expose — don't publish every scalar property by default. - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided - field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + field names — it must mirror the actual publisher. Locate the source entity's `[Publish]` attribute (if it's locally visible, e.g. another app in this same workspace) and derive: - the class name (must match the publisher's `TypeName` - its published type's simple class + the class name (must match the publisher's `TypeName` — its published type's simple class name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, - not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + not a structural copy of the source's navigation shape — see AGENTS.md's Subscribing example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and leaf property names/types instead of guessing a shape that has to match byte-for-byte for the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. -## File 1 - Data model +## File 1 — Data model Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` in the single-project layout. @@ -95,44 +95,44 @@ public class : BaseEntity } ``` -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` — match the project's existing convention (see step 5 above). - For restricted CRUD (e.g. read-only, or no delete), derive instead from `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` - + `BaseEntityCreatableAndUpdatable`, `BaseEntityCreatableAndDeletable`, or `BaseEntityDeletable` — ask the user if the request implies one of these rather than full CRUD. - **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in - `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + `EntityEventingHandler` whenever the publishing app's source entity changes — update/delete normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller - (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + (File 4) regardless of what tier was otherwise requested — Edit/Delete shouldn't be exposed to callers on a subscribed entity. The entity's shape itself (class name, properties) comes from step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" - step 2 - don't invent field names for a `[Subscribe]` entity. -- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + step 2 — don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape — they're scaffolded exactly like any other entity, just additionally marked for replication. - Use `required`/`= null!` per the project's existing nullable-reference style, not your own default. -- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column +- **Every `string` property gets an explicit `[MaxLength(n)]`** — never leave a string column unbounded. Pick `n` from what the property actually represents, not one number applied mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still - - use `128` as the reasonable default when nothing about the property's own meaning suggests a + — use `128` as the reasonable default when nothing about the property's own meaning suggests a different size, not as a rule to apply without thinking. This annotation and File 2's - `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. + `.HasMaxLength(n)` must agree — see File 2's note on keeping both in sync. - **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the property, matching whatever the property is initialized to in the class (`= true`/ - `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `= MyEnum.SomeMember`) — don't leave a `bool`/`enum` property's default implicit (C#'s own `false`/first-member-value default) without stating it, since that's exactly the kind of intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` must match the same value. - **A `string` property File 3's query criteria will match with `StartsWith`/`Contains`/`Equal` for - human-typed search or lookup gets a computed `XNormalized` twin** - see AGENTS.md's `##### Case-insensitive + human-typed search or lookup gets a computed `XNormalized` twin** — see AGENTS.md's `##### Case-insensitive search (Normalized columns)` for the full pattern and when to skip it (exact technical identifiers, seeded - constants). Decide this together with File 3, not in isolation - if the request doesn't say whether the + constants). Decide this together with File 3, not in isolation — if the request doesn't say whether the entity needs a search/lookup field at all yet, this only applies once one exists. -- **Date/time properties are always named `...At`** - `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, +- **Date/time properties are always named `...At`** — `CreatedAt`, `StartsAt`, `EndsAt`, `CancelledAt`, `ShreddedAt`, never `...Date`, `...Timestamp`, or a `...Utc` suffix (a `DateTime` holding UTC is still just `...At`). Name it after the event: past tense for something that happened (`ClosedAt`), a plain "starts/ends" form for a boundary (`StartsAt`, `EndsAt`). This carries through everywhere the property appears: the mapping, @@ -145,62 +145,61 @@ public class : BaseEntity - **Every reference navigation property (`virtual TOther Nav`) is typed nullable (`TOther? Nav`), even when the relationship is required and the FK scalar itself is non-nullable.** ASP.NET Core's model binding treats a non-nullable reference-type property as implicitly required on an incoming request body, with no `[Required]` - attribute needed to trigger it - a non-nullable navigation on an entity that's also a request/response shape + attribute needed to trigger it — a non-nullable navigation on an entity that's also a request/response shape turns into an unintended required field, typically surfacing as an unexplained 400 on a request that never - meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs - enforced - by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` - the navigation property itself stays + meant to supply that navigation at all. The FK scalar (`TOtherId`) is where real requiredness belongs — enforced + by the database via File 2's `.IsRequired()`/`.HasForeignKey(...)` — the navigation property itself stays nullable regardless of cardinality. This applies to every reference navigation on every entity, not just ones the request happens to touch today: an entity's shape can end up serialized into a request/response later even if it isn't today, so getting this right at scaffold time avoids a mapping-wide retrofit once it is. -## File 2 - Data mapping +## File 2 — Data mapping Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). +the split layout — mappings are EF Core-only, never part of the shared API client models). ```csharp public class Mapping : BaseEntityMapping<> { - public override void Configure(EntityTypeBuilder<> builder) + protected override void ConfigureEntity(EntityTypeBuilder<> builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); - builder .Property(x => x.Name); } } ``` -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- **Configure every one of the entity's own properties explicitly - including navigations and - collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, +- **Override `ConfigureEntity`, never `Configure`.** `Configure` is not `virtual` (overriding it is + compile error CS0506); Nano applies its inherited behavior (soft delete, audit, etc.) itself + before calling `ConfigureEntity`, so there is no `base` call to make. Everything the mapping + configures goes in `ConfigureEntity`. +- **Configure every one of the entity's own properties explicitly — including navigations and + collections — never rely on EF's conventions to fill in what isn't written down.** An implicit, convention-inferred relationship is exactly the kind of mistake that's invisible until it's a production bug (e.g. EF silently creating a shadow FK column for a stray navigation property with no real relationship behind it). Being explicit is what makes a mistake visible on read, not what EF happens to guess correctly most of the time. -- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ +- **List properties in the same order they're declared on the entity** — one `.Property(...)`/ `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This makes the mapping file scannable against the entity file side by side: a missing or out-of-place property is immediately visible, not something that only surfaces when something breaks at runtime. - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` - exactly - the two must agree; a mismatch means the database allows more than the API's own + exactly — the two must agree; a mismatch means the database allows more than the API's own model validation does, or vice versa. For a `bool`/`enum` property, add `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# - default - explicit at the database level too, not just in the model. + default — explicit at the database level too, not just in the model. - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** - on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + on the same chain — never leave delete behavior to EF's own inferred default (`Cascade` for a required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when the desired behavior happens to match what EF would infer anyway: the point is that a reader (or a future edit) sees the intended behavior written down, not that it produces a different - result today. Pick the value deliberately per relationship - `Cascade` when the dependent + result today. Pick the value deliberately per relationship — `Cascade` when the dependent genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the parent while dependents still exist should be a hard error instead of silently taking them with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` @@ -217,29 +216,29 @@ public class Mapping : BaseEntityMapping<> "isn't used" - an unnamed end is exactly what hides a missing or accidental relationship. - **Inverse collection/reference navigation with no FK of its own** (the principal side of a relationship whose FK is declared in the *dependent* entity's own mapping): configure it - explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + explicitly here too, not just with a comment — `.HasMany(x => x.Children).WithOne(x => x.Parent)` (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever the dependent's own FK property is non-nullable (matching what the dependent side's own - `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + `.HasForeignKey(...)` chain already declares) — **without** repeating `.HasForeignKey(...)` itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call - from the dependent side's chain here too** - declaring delete behavior from both ends, + from the dependent side's chain here too** — declaring delete behavior from both ends, matching, is what makes it visible when scanning *this* entity's mapping file alone, the same reasoning as declaring the relationship itself from both ends. EF Core matches the two configurations by navigation pairing and merges them as the same relationship, so writing it from both ends is safe as long as they agree. **No comment needed** for the relationship/FK - itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + itself — the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a comment repeating "FK owned/declared in the other file" for every single one of these is noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth duplicating, not narrating. - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually support what the property implies): don't let it fall through to an accidental EF-invented - shadow relationship. Flag it to the user and ask what it should be - don't guess a + shadow relationship. Flag it to the user and ask what it should be — don't guess a relationship that isn't in the model. If the user says to leave the property in place without resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather than leaving it for EF's convention to silently invent something. - **A unique constraint** (a property, or a combination of properties, that must be unique): `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a - composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + composite key) — explicit, never left for a `[Key]`-adjacent attribute or assumed convention to imply. Ask the user which properties (if any) need this if it isn't already obvious from the request (e.g. "domain must be unique across all tenants"). - **Many-to-many relationships are modeled as an explicit join entity, never @@ -248,15 +247,15 @@ public class Mapping : BaseEntityMapping<> timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File - 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + 1/File 2 pair — a normal one-to-many-to-one shape from each side, not a special case — even when the join entity currently has no columns beyond the two FKs. A join entity with nothing but its foreign keys is only ever added and removed, never edited (editing one would just re-point it), so it derives from `BaseEntityCreatableAndDeletable` in File 1 and gets `BaseEntityCreatableAndDeletableController` in File 4. -- **Index every property the entity is queried or sorted by** - one `HasIndex(...)` in the mapping for each +- **Index every property the entity is queried or sorted by** — one `HasIndex(...)` in the mapping for each property File 3's query criteria filters on, and each property an ordering (`Order.By`) or a keyword search reaches, declared directly beneath the mapping of the property it indexes, not collected at the end - of `Configure` (a composite index goes beneath the last of its properties to be mapped, after that + of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped, after that property's relationship block if it is a foreign key). Foreign keys already get an index from EF's conventions, so never write a single-column `HasIndex` on a foreign key property, and none at all when a composite index or unique index already starts with it; @@ -266,14 +265,14 @@ public class Mapping : BaseEntityMapping<> the same leading property. An index only helps a text search that anchors at the start of the value, which is why File 3 uses `StartsWith`, not `Contains`. If it isn't clear from the request how the entity is listed, searched, and sorted, ask before guessing which indexes it needs. -- No registration step needed - Nano auto-discovers mappings via +- No registration step needed — Nano auto-discovers mappings via `ModelBuilderExtensions.MapEntities` at startup. - Add a new EF Core migration after this file exists: `dotnet ef migrations add ` from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for + existing workflow clearly expects a migration per entity — check `Migrations/` for precedent first). -## File 3 - Query criteria (API/Web only - skip for Console) +## File 3 — Query criteria (API/Web only — skip for Console) Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or `Criterias/QueryCriteria.cs` in the single-project layout. @@ -303,22 +302,22 @@ public class QueryCriteria : BaseQueryCriteria } ``` -- Only add filter properties for fields that make sense to search/filter by - don't +- Only add filter properties for fields that make sense to search/filter by — don't mechanically add one filter per scalar property on the entity. - Every filter property must be `virtual` and nullable. -- **Text search uses `StartsWith`, never `Contains`** - a `Contains` (leading wildcard) can't use an index +- **Text search uses `StartsWith`, never `Contains`** — a `Contains` (leading wildcard) can't use an index and scans the whole table as it grows; `StartsWith` can. Whatever a criteria property filters on gets a matching `HasIndex` in File 2's mapping. - **Search/lookup against a `string` property filters on its `XNormalized` twin (see File 1), with the input - itself also uppercased** (`this.Name.ToUpper()`) - never match the raw property directly for a + itself also uppercased** (`this.Name.ToUpper()`) — never match the raw property directly for a `StartsWith`/`Contains`/`Equal` a human might type in any casing. File 2's index goes on the `Normalized` property too, not the raw one. - Use the `CriteriaExpression` builder methods appropriate to each property's type (`StartsWith` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, + — check the project's other query criteria classes for the operations actually available, don't guess. -## File 4 - Controller (API/Web only - skip for Console) +## File 4 — Controller (API/Web only — skip for Console) Location: `Controllers/sController.cs` in the main app project. @@ -332,33 +331,33 @@ public class sController(ILogger<sController> logger, IRepositor - **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, - `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + `Country` → `CountrysController` — note this is naive `+s` pluralization, not proper English plural rules; Nano derives the route segment from the class name). Do not "correct" irregular plurals. - Check whether the project actually registers an eventing provider (look for `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing + parameter and the corresponding base-constructor argument — an unused optional eventing dependency is harmless, but match what sibling controllers in the same project actually do. - If the identity type isn't `Guid` (per step 5), the controller generic list needs the identity type too: `BaseEntityController<, , QueryCriteria>`. - **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every entity, with no exceptions other than `[Subscribe]` and a foreign-keys-only join entity (below).** Having custom actions on the same - controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + controller — even ones that overlap in intent with a generic CRUD action — is not on its own a reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to remove generic capability the entity is otherwise entitled to. - **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, - not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + not `BaseEntityController` — per File 1's note, update/delete happen through the Subscribe mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. - **A join entity with only foreign keys uses `BaseEntityCreatableAndDeletableController<, QueryCriteria>`** (Get/Query/Create/Delete, - no Edit) - see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is + no Edit) — see File 2's many-to-many note. Any rule that must hold on add or remove (e.g. the parent is immutable once in use) is then an override of the create/delete actions only, with no edit path left open around it. These two are the only cases that change the default tier. -- No manual registration needed - Nano's MVC discovery picks up the controller +- No manual registration needed — Nano's MVC discovery picks up the controller automatically from the assembly. -## Postman collection (API/Web only - skip for Console) +## Postman collection (API/Web only — skip for Console) If the application already has a Postman collection (`Postman_.json` in the application's own folder, next to its `README.md`), add the new entity's folder to it, following the @@ -367,14 +366,14 @@ class exposes (see File 4), placed in FK-dependency order, with Id/name chaining new folder; don't regenerate or reorder the rest of the file, and remind the user to **Replace**-import it in Postman. -**If no collection exists, do nothing Postman-related** - don't create one. The entity is picked up when the +**If no collection exists, do nothing Postman-related** — don't create one. The entity is picked up when the user later generates the collection with `nano-add-postman-collection`. ## After generating - Show the user the files generated and where they were placed (two for Console, four for API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run - `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + `dotnet ef migrations add` unless they ask — scaffolding the entity is the task, not deciding the rest of the rollout for them. - If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. + reference, mention it — it's the fastest way for the user to sanity-check the result. diff --git a/Web._Blank/.github/prompts/nano-add-identity.prompt.md b/Web._Blank/.github/prompts/nano-add-identity.prompt.md index 2be816e3..5a670779 100644 --- a/Web._Blank/.github/prompts/nano-add-identity.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-identity.prompt.md @@ -6,13 +6,13 @@ description: Configure Nano's persistent Identity store (Data:Identity) on a Nan # Nano add identity Configures Nano's persistent user/role/claim store on an existing Nano API, Web, or Console -application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first - it documents the +application. Read `AGENTS.md`'s `## Nano.Data` → `#### Identity` section first — it documents the full `Configuration` table and the auto-created roles in detail; this skill does not repeat that, only how to apply it and add the `User` entity Nano's identity actions attach to. **Identity is a separate concern from Authentication.** `Data:Identity` (this skill) is the persistent *store* for users/roles/claims; `App:Authentication` (JWT/API key login) is a -different, independently-configurable section - AGENTS.md's own `#### Authentication` documents +different, independently-configurable section — AGENTS.md's own `#### Authentication` documents JWT working standalone with no Identity at all ("transient" auth). This skill adds accounts and identity-management endpoints (sign-up, password, roles, claims, API keys); it does not add any way to log in. If the user actually wants login/JWT, that's a different skill. @@ -21,54 +21,54 @@ way to log in. If the user actually wants login/JWT, that's a different skill. 1. **Is this app meant to be a Public API, or an internal service?** Per AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service): a Public API composes Api - Clients into responses and has **no `IRepository` of its own** - Identity (a Data provider + + Clients into responses and has **no `IRepository` of its own** — Identity (a Data provider + `IIdentityRepository`) structurally doesn't belong there. `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, safe only on an - internal network - never on an app reachable directly from the internet. If the request is + internal network — never on an app reachable directly from the internet. If the request is actually "add login/signup to our Public API," that's **not** this skill: point the user at composing through the owning internal service's Api Client (`.Identity`/`.Auth` method groups) instead, or at `nano-add-authentication-jwt`'s transient-auth path if this app needs to mint its own tokens with server-computed claims. Only proceed with this skill once it's confirmed this app is (or is becoming) the internal service that actually owns the `User` entity. - **Also check whether this app is already publicly exposed** - look for + **Also check whether this app is already publicly exposed** — look for `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-public-exposure` checks). Intent (above) and fact can disagree: an app nobody meant to expose may have been anyway, or an app built as an internal service may have picked up public exposure later for an unrelated reason. If either file is present, **stop before - touching anything** and flag it explicitly - adding `BaseEntityUserController` here would put + touching anything** and flag it explicitly — adding `BaseEntityUserController` here would put its anonymous password-reset endpoints on the open internet the moment this skill finishes, not as a hypothetical to caveat afterward. Get the user's explicit confirmation this is intentional before proceeding. 2. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or - provider - with none registered, stop and tell the user a Data provider needs to be added + provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). 3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired - to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity — unusual, but not something to do silently). - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the - narrower capability bases) - this app already has its own reason for this entity to exist, + narrower capability bases) — this app already has its own reason for this entity to exist, independent of Identity. **Don't create a second, conflicting class.** Convert the existing one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, and carry every existing custom property and any existing controller's custom actions over - unchanged - this is an addition to what's there, not a replacement. First check its existing + unchanged — this is an addition to what's there, not a replacement. First check its existing properties against what `BaseEntityUser` already provides (username, email address, phone - number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - - **Doesn't exist at all** - create fresh, as below. + - **Doesn't exist at all** — create fresh, as below. 4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a - separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, + separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. 5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` - (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and + (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. 6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or - `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full + `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive identity from repository code in a worker. @@ -77,52 +77,51 @@ way to log in. If the user actually wants login/JWT, that's a different skill. Add the `Data:Identity` section from AGENTS.md's `#### Identity` example to the base `appsettings.json`, as a sibling of `ConnectionString` under `Data`. Unlike `ConnectionString`, -the whole section lives in the base file - nothing in it is an environment-specific secret, so +the whole section lives in the base file — nothing in it is an environment-specific secret, so there's no `appsettings.Development.json` split to make. - Default `UseAudit` to `"None"` (the framework default) unless the user asks for identity models to be audited. -- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) - it's meaningful only +- **Leave `ApiKey` out entirely** (don't set even a `null` placeholder) — it's meaningful only once API-key authentication is added on top, which is Authentication's job, not this skill's; adding it here with nothing consuming it yet is dead config. ## User entity, mapping, and controller This is the entity-scaffold skill's file set, with identity-specific base classes in place of -the plain ones - read that skill first for the file-location/project-layout rules (split +the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 3), this is the *only* change to the class itself - just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties - the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace - `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally + `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 3), convert its existing mapping file the same way - just the base class; - keep every custom `Configure(...)` statement already in it, still calling - `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first - rule as a normal mapping. -- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing + entity** (step 3), convert its existing mapping file the same way — just the base class; + keep every custom statement already in its `ConfigureEntity(...)` as it is. **If creating + fresh**, same shape as a normal mapping (a `ConfigureEntity` override, no `base` call). +- **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 — nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity - already had a controller with custom actions**, keep every one of them - this change is the + already had a controller with custom actions**, keep every one of them — this change is the base class and the added constructor dependency, nothing else. ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter pattern** the plain entity controllers (and this skill's own description above) might suggest. - It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + It exposes **two distinct constructor overloads** instead — one with no eventing parameter at all, one with a **required, non-nullable** `IEventing eventing`: ```csharp // No eventing provider registered in this project @@ -133,7 +132,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload — don't write `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter @@ -142,25 +141,25 @@ convert, in which case its existing properties carry over as-is; don't ask for t This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't - enabled) aren't registered at all - nothing further to do for those until that's added. + enabled) aren't registered at all — nothing further to do for those until that's added. ## Api Client side If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) -- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +— that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, etc.) for every consumer of this client. A client left on the plain base class after Identity is added has no way to expose any of the endpoints this skill just enabled. Check for an existing -client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change — don't leave that as a follow-up the user has to remember separately. ## After making the change - Show the user every file touched. - Remind them explicitly: this adds accounts and identity-management endpoints, but no way to - log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works + log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/Web._Blank/.github/prompts/nano-add-postman-collection.prompt.md b/Web._Blank/.github/prompts/nano-add-postman-collection.prompt.md index 52023088..935797c2 100644 --- a/Web._Blank/.github/prompts/nano-add-postman-collection.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-postman-collection.prompt.md @@ -6,12 +6,12 @@ description: Generate a Postman collection for a Nano.Library-based application # Nano add Postman collection Generates a Postman v2.1.0 collection JSON for one Nano application. Read `AGENTS.md` in the -target repo root first - its `#### Public API vs internal service` section is the single most +target repo root first — its `#### Public API vs internal service` section is the single most important input here: it changes the base URL scheme, which environments the collection can even be run against, and (for a Public API) whether Auth is meaningful at all. **One collection per application, always.** Never combine multiple applications' folders into one -shared collection file - even when generating collections for several apps in the same session, +shared collection file — even when generating collections for several apps in the same session, each gets its own file. A shared collection makes the URL-scheme distinction below unrepresentable (a Public API and an internal service need different base URLs in the same collection), and makes it impossible to swap the Postman auth token between apps without stepping on another app's tests. @@ -19,34 +19,34 @@ it impossible to swap the Postman auth token between apps without stepping on an ## Before generating anything, determine 1. **Is this app a Public API or an internal service?** Per AGENTS.md's `Public API vs internal - service` section - check for `.kubernetes/httproute-*.yaml` (public exposure, see + service` section — check for `.kubernetes/httproute-*.yaml` (public exposure, see `nano-add-public-exposure`) or `App:Hosting:Https` in `appsettings.json`. This decides the base - URL scheme (below) and which environments the collection is even runnable against - don't + URL scheme (below) and which environments the collection is even runnable against — don't guess from the app's name. 2. **What controllers exist, and what does each one's own base class actually expose?** Read every - controller's declaration line - never assume full CRUD. Check which `BaseEntity*Controller` it + controller's declaration line — never assume full CRUD. Check which `BaseEntity*Controller` it inherits (see AGENTS.md's `#### Entity controller hierarchy`) and only generate the actions that tier actually has: - - `BaseEntityReadOnlyController` - Read only (Index, Details, Details Many, Query, Query First, + - `BaseEntityReadOnlyController` — Read only (Index, Details, Details Many, Query, Query First, Query Count). No Create/Edit/Delete folder at all. - - `BaseEntityCreatableController` - Read + Create (Create, Create Or Get, Create And Reload, - Create Many). **No Create Or Edit** - that action needs Edit capability, which this tier + - `BaseEntityCreatableController` — Read + Create (Create, Create Or Get, Create And Reload, + Create Many). **No Create Or Edit** — that action needs Edit capability, which this tier doesn't have; don't include it just because every other tier you've seen does. - - `BaseEntityEditableController` - Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). + - `BaseEntityEditableController` — Read + Edit (Edit, Edit And Reload, Edit Many, Edit Query). No Create, no Delete. - - `BaseEntityDeletableController` - Read + Delete (Delete, Delete Many, Delete Query). No + - `BaseEntityDeletableController` — Read + Delete (Delete, Delete Many, Delete Query). No Create, no Edit. - - `BaseEntityCreatableAndDeletableController` - Read + Create (Create, Create Or Get, Create And + - `BaseEntityCreatableAndDeletableController` — Read + Create (Create, Create Or Get, Create And Reload, Create Many) + Delete (Delete, Delete Many, Delete Query). No Edit, so no Create Or Edit. - - `BaseEntityCreatableAndEditableController` - Read + Create + Edit, including Create Or Edit + - `BaseEntityCreatableAndEditableController` — Read + Create + Edit, including Create Or Edit now that both halves exist. - - `BaseEntityController` - full CRUD (Read + Create + Edit + Delete). - - `BaseEntityUserController` - the full Identity surface (SignUp, password/email/phone + - `BaseEntityController` — full CRUD (Read + Create + Edit + Delete). + - `BaseEntityUserController` — the full Identity surface (SignUp, password/email/phone set-change-confirm flows, activate/deactivate, user roles/claims, refresh tokens, plus the - Read/Edit/Delete tier) - see `nano-add-identity`/`nano-add-authentication-jwt` for the exact + Read/Edit/Delete tier) — see `nano-add-identity`/`nano-add-authentication-jwt` for the exact route list, or read `BaseEntityUserController.cs` directly for the current one. A `[Subscribe]` entity (AGENTS.md's Entity Events) is a strong signal its controller is - `BaseEntityCreatableController` by convention - call this out in the collection (a folder + `BaseEntityCreatableController` by convention — call this out in the collection (a folder description noting "replica kept in sync by eventing, not normally created directly") rather than leaving it looking like an arbitrary restriction. 3. **Every custom (non-generic) action on each controller.** Read its `[Route(...)]`/HTTP-verb @@ -54,21 +54,21 @@ it impossible to swap the Postman auth token between apps without stepping on an to know before calling it: `[AllowAnonymous]`, a business-rule guard that throws `BadRequestException` under some condition (e.g. an archived parent record), or a side effect that isn't a direct write (e.g. publishing an event for async processing rather than inserting a - row) - put this in the request's `description` field, not just in your own notes. + row) — put this in the request's `description` field, not just in your own notes. 4. **Is `Data:Identity` configured?** (`appsettings.json`'s `App:Data:Identity`, or check `Program.cs` for `.AddNanoIdentity(...)`.) If it's `null`, the plain username/password Login, - Login Refresh, and Logout actions have no user store to authenticate against and won't work - + Login Refresh, and Logout actions have no user store to authenticate against and won't work — the Auth folder is **Login Root only**. If Identity is configured, include the full Login/Login Root/Login Refresh/Logout set, plus a Login request per configured `ExternalLogins` provider - (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` - see - `nano-add-authentication-microsoft`) - see External login requests below for what each one's + (Microsoft/Google/Facebook, from `App:Authentication:Jwt:ExternalLogins` — see + `nano-add-authentication-microsoft`) — see External login requests below for what each one's `description` needs. 5. **Root login credentials.** Read `appsettings.Development.json`'s `Jwt.RootLogin.Username`/ - `Password` **for this specific app** - never copy another app's credentials, they're + `Password` **for this specific app** — never copy another app's credentials, they're per-app-configured and commonly differ. 6. **Local dev port.** For an internal service, the docker-compose-mapped host port (usually - `8080` - check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS - dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here - Postman + `8080` — check `.docker/docker-compose.yml`, don't assume). For a Public API, the local HTTPS + dev port from `Properties/launchSettings.json`'s `applicationUrl` is irrelevant here — Postman should hit the same `{{port_https}}` environment variable every other Public API collection uses (see Environments below), not a per-app literal. 7. **Entity dependency order**, so the collection can actually be run top-to-bottom: an entity with @@ -163,42 +163,49 @@ bodies reference the earlier entity's `{{Entity.Id}}` for their FK fields, so th run top-to-bottom without manual value substitution - order the collection's top-level folders to match the actual FK dependency chain (see step 7 above), not alphabetically. +## Description length + +Keep every `description` (collection, folder, request) short, never an essay: one or two sentences +for most requests, three to five only for a complex endpoint. Drop anything a tester doesn't need, +but never drop something essential to actually running the request (the external login authorize +URL, a required precondition, an auth note). + ## External login requests A `Login {Provider}` request's body only carries what the *backend* needs (`Code`/`CodeVerifier`/ -`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) - none of that can be produced by +`RedirectUri` for Microsoft/Google, `AccessToken` for Facebook) — none of that can be produced by Postman itself, since it's the *frontend* that has to redirect a real browser through the provider's own sign-in first. Put the frontend's side of that exchange in the request's `description`, as the actual authorize-redirect URL (or SDK note for Facebook) with placeholders, so a tester doesn't have to go hunting for it elsewhere: - **Source the URL template from `Nano.App.Api` README's `#### Microsoft`/`#### Google` sections** - (under External Logins) - don't reproduce your own version of the query-string shape from + (under External Logins) — don't reproduce your own version of the query-string shape from memory, that section is the one place PKCE parameters, scopes, and (for Google) `access_type`/`prompt` are kept correct; copy the template from there so this collection can't drift from it. - **Resolve `{ClientId}`/`{TenantId}` from this app's own config** (`appsettings.Development.json`'s - `App:Authentication:Jwt:ExternalLogins:{Provider}`) - these are real, per-app values, not + `App:Authentication:Jwt:ExternalLogins:{Provider}`) — these are real, per-app values, not placeholders, so put the actual configured value in the URL. -- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection - use +- **`redirect_uri` also doesn't need to be a real per-app frontend URL for a test collection — use the same fixed local value every time**: `http://localhost/auth`. Put it directly in the authorize URL in the description (in place of a `{redirect_uri}` placeholder), and set `Auth.Microsoft.RedirectUri` (or the Google equivalent)'s **collection variable default** to this same value, so `Login {Provider}`'s `RedirectUri` field already matches the URL in the description out of the box. This value has nothing to do with the app's own hosting/CORS - config - Microsoft/Google only compare it byte-for-byte against what the app registration's + config — Microsoft/Google only compare it byte-for-byte against what the app registration's redirect URI allowlist contains, so `http://localhost/auth` only actually works end-to-end once it's added to that allowlist too (`nano-add-authentication-microsoft`'s `AUTH_MICROSOFT_REDIRECT_URI`/`--web-redirect-uris` is developer-supplied, not fixed by that - skill - add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so + skill — add `http://localhost/auth` there, or add it as an extra entry alongside a real one, so this collection's fixed value is actually registered and not just a placeholder that looks real). Document that overriding it (with a real frontend callback URL, registered to match) works too, same as `code_verifier`/`code_challenge` below. - **`state` and `code_challenge`/`code_verifier` don't need to be freshly random per attempt for a - test collection - use the same fixed values every time.** `state` is inert to everyone but the + test collection — use the same fixed values every time.** `state` is inert to everyone but the frontend itself (nothing server-side ever checks it), so put the literal `12345` in the URL - instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder - - Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed - + instead of a `{state}` placeholder. `code_challenge` genuinely can't be an arbitrary placeholder — + Microsoft's token endpoint checks it against `SHA256(code_verifier)` when the code is redeemed — but it also doesn't need to change between runs, so use this fixed, real, already-matching pair in every collection this skill generates, rather than computing a new one each time: - `code_verifier`: `Postman-DevTest-CodeVerifier-Fixed-Value-1234567890-ABCDEFGHIJK` @@ -207,17 +214,17 @@ so a tester doesn't have to go hunting for it elsewhere: Put the `code_challenge` value directly in the authorize URL in the description. Set `Auth.Microsoft.CodeVerifier` (or the Google equivalent)'s **collection variable default** to the `code_verifier` value above, so the paired `Login {Provider}` request already sends a verifier - that matches out of the box - the tester only has to fill in `Code`/`RedirectUri` from a real + that matches out of the box — the tester only has to fill in `Code`/`RedirectUri` from a real redirect, not reconstruct a matching PKCE pair by hand. Still document that overriding the variable (with a freshly generated pair, and a matching `code_challenge` in the URL) works too, for anyone who wants a real per-attempt PKCE exchange instead of the fixed test pair. -- **For Microsoft, `TenantId` is not always the literal `organizations`** - it depends on which +- **For Microsoft, `TenantId` is not always the literal `organizations`** — it depends on which `--sign-in-audience` this specific app was set up with (check its own CI workflow's "Setup App Registration" step, not another app's). Per the sign-in-audience table, `AzureADMyOrg` means `TenantId` is the real tenant GUID (`AZURE_TENANT_ID`), `AzureADMultipleOrgs` means the literal - `organizations`, and so on - two apps in the same solution can genuinely use different audiences, + `organizations`, and so on — two apps in the same solution can genuinely use different audiences, so don't carry a value over from one app's collection into another's without checking. -- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section - it's an +- **Facebook has no authorize-redirect URL at all** (AGENTS.md's Authentication section — it's an implicit flow, the client-side Login SDK obtains the access token directly). Don't fabricate one; instead note in the description that the frontend uses the Facebook Login SDK to obtain an `AccessToken` client-side and sends it straight through. @@ -225,43 +232,46 @@ so a tester doesn't have to go hunting for it elsewhere: ## .Prerequisites folder Sometimes this app's own collection needs test data that only a *different* application can -create - a Public API's Postman collection can't test anything before a real, finalized User +create — a Public API's Postman collection can't test anything before a real, finalized User exists, but that Public API itself has no signup of its own; a Public API's read-only reference data (Currencies, Product Categories) may have no Create endpoint in that app at all. When this happens, don't just note it in a description and leave the tester to go run a different -collection by hand first - add a `.Prerequisites` folder (the leading dot sorts it first in +collection by hand first — add a `.Prerequisites` folder (the leading dot sorts it first in Postman's sidebar, ahead of Auth) with the minimum requests needed to unblock this collection's own flow. - **Target the actual owning application directly, never a composing Public API.** A Public API - has no data of its own - it's always some internal service + has no data of its own — it's always some internal service that actually owns the entity, per AGENTS.md's Public API vs - internal service section. Go straight there, using that service's own base URL scheme - (`http://{{host}}:{{port}}` - see Base URL scheme above), not through another Public API's - composed endpoint. This also means `.Prerequisites` only ever works against `Development` (the - same reason an internal service's own collection is Development-only) - say so in the folder's + internal service section. Go straight there, not through another Public API's composed endpoint. + Use `http://{{host}}:` with that service's literal host port, read from the nested service + block in this app's own `.docker/docker-compose.yml` (each nested service is mapped to a unique + port so they can all run together) - never `{{port}}`, which only fits a service's own collection. + Use one shared `Prereq.Auth.Token` variable for every service's root login; each login simply + overwrites the previous one. This also means `.Prerequisites` only ever works against `Development` (the + same reason an internal service's own collection is Development-only) — say so in the folder's `description`, even when the rest of this collection runs against all three environments. - **Create-only, not full management.** The point is unblocking this collection's own flow, not - duplicating the owning application's own collection - one (or a short chain of) Create request(s) + duplicating the owning application's own collection — one (or a short chain of) Create request(s) for exactly the entity/entities this collection can't create itself, nothing more. That owning entity's real Read/Edit/Delete surface stays in its owning application's own collection, not here. -- **Reuse whatever auth the owning service actually offers for this** - its own Login Root if one +- **Reuse whatever auth the owning service actually offers for this** — its own Login Root if one exists and its role-based authorization is enough for the generic Create actions involved (see AGENTS.md's Authorization policy table), or an existing anonymous endpoint if the entity in question genuinely has one (e.g. `signup`). Don't invent new backend capability just to make this - folder anonymous - if the owning service's real routes require an authenticated call to create + folder anonymous — if the owning service's real routes require an authenticated call to create the prerequisite data, that's the truth to reflect, not something to route around. - **Wire the result into the variables this collection's own requests already expect.** If Auth / Login expects `User.EmailAddress`/`User.Password`, the last `.Prerequisites` request's test - script should set exactly those - not a separately-namespaced variable the rest of the collection + script should set exactly those — not a separately-namespaced variable the rest of the collection never reads. - **Place it where its own FK dependencies are actually satisfied, not always at the top.** A `.Prerequisites` folder with no dependency on anything else in this collection (bootstrapping the very first User, e.g.) is the collection's first top-level folder, ahead of Auth. One that needs an Id this collection's own earlier folders create (e.g. an Assets folder needs an Organization that only exists in another service, but that Organization itself needs this collection's own - already-created `Tenant.Id`) can't run before that - nest a `.Prerequisites` folder *inside* the + already-created `Tenant.Id`) can't run before that — nest a `.Prerequisites` folder *inside* the specific folder that needs it instead, right before that folder's own requests, so it runs at the point in the collection where its own inputs actually exist. Don't force every prerequisite into one global first folder just for consistency if doing so would make it reference a variable diff --git a/Web._Blank/AGENTS.md b/Web._Blank/AGENTS.md index c816d4d7..db5d8bba 100644 --- a/Web._Blank/AGENTS.md +++ b/Web._Blank/AGENTS.md @@ -2102,10 +2102,8 @@ public class MyEntity : BaseEntity ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); - builder.Property(x => x.Name).IsRequired().HasMaxLength(128); builder.Property(x => x.NameNormalized).IsRequired().HasMaxLength(128); @@ -2869,10 +2867,9 @@ Each entity gets a matching `IEntityTypeConfiguration`-style mapping cl ```csharp public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - base.Configure(builder); // always first — wires soft-delete filter + CreatedAt/IsDeleted indexes builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.HasIndex(x => x.Name); @@ -2883,17 +2880,19 @@ public class MyEntityMapping : BaseEntityMapping | Base class | Derives from | Adds | | --------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- | -| `BaseMapping` | `IEntityTypeConfiguration` | Nothing — the root; only entities with **no** `Id` skip everything below it. | +| `BaseMapping` | `IEntityTypeConfiguration` | The root: runs Nano's inherited configuration, then your `ConfigureEntity`, which is `abstract` here (a mapping deriving from it directly must implement it; every base class below provides an empty default). Adds nothing itself; only entities with **no** `Id` skip everything below it. | | `BaseEntityIdentityMapping` | `BaseMapping` | `HasKey(Id)` + a value generator (`GuidValueGenerator` for `Guid`; a generic `ValueGenerator` type otherwise — verify this actually works for your chosen non-`Guid` identity type before relying on it). | | `BaseEntityMapping` / `` | `BaseEntityIdentityMapping<...>` | Soft-delete query filter (`IsDeleted == 0`), `CreatedAt` (auto-generated on add, ignored after save, indexed), `IsDeleted` (default `0`, indexed). | | `BaseEntityUserMapping` / `` | `BaseEntityIdentityMapping<...>` (not `BaseEntityMapping`) | Same `CreatedAt`/`IsDeleted` config as above, **plus** a query filter requiring `IdentityUser.IsActive`, **plus** a required 1:1 relationship to `IdentityUser` with cascade delete. | | `BaseEntityViewMapping` | `BaseMapping` | `ToView(typeof(TEntity).Name).HasNoKey()` — maps to a SQL view by entity type name, no key at all. | -⚠ Always call `base.Configure(builder)` **first** in an override — every behavior in the table above depends on -it running before your own configuration. +⚠ Override `ConfigureEntity`, never `Configure` — `Configure` is not `virtual`, so overriding it is a compile error +(CS0506). Nano applies everything in the table above itself, before it calls `ConfigureEntity`, so there is no +`base` call to make (or forget), and your configuration always runs after Nano's defaults. A mapping with nothing to +add can be an empty class (when it derives from one of the base classes below `BaseMapping`). ⚠ Declare each `HasIndex(...)` directly beneath the mapping of the property it indexes, not collected at the end -of `Configure` (a composite index goes beneath the last of its properties to be mapped). Never write a +of `ConfigureEntity` (a composite index goes beneath the last of its properties to be mapped). Never write a single-column `HasIndex` on a foreign key property: EF Core already creates an index for every foreign key, and skips it when a composite or unique index already starts with that property. @@ -2907,11 +2906,11 @@ ends are explicit. `BaseDbContext.OnModelCreating` calls `modelBuilder.MapEntities()`, which reflects the entry assembly for every non-abstract, non-generic class deriving `BaseMapping` — **a raw `IEntityTypeConfiguration` not derived from `BaseMapping` is not picked up.** For each one found, it -instantiates and calls `Configure`, then unconditionally applies two automatic index adjustments to every mapped -entity: +instantiates and calls `Configure` (Nano's inherited configuration, then your `ConfigureEntity`), then +unconditionally applies two automatic index adjustments to every mapped entity: - **Every unique index gets renamed** to a canonical `UX_{table}_{col1}_{col2}...` database name, regardless of - what name (if any) you gave it in `Configure`. + what name (if any) you gave it in `ConfigureEntity`. - **If the entity implements `IEntitySoftDeletable`**, every unique index that doesn't already include `IsDeleted` (and isn't just the primary key) is rebuilt to add `IsDeleted` as an extra composite column — this is the actual mechanism behind [Soft Delete](#soft-delete)'s "unique indexes are adjusted automatically" rule. @@ -3211,7 +3210,7 @@ response. ### Triggers Code-level hooks around save operations — not SQL triggers. Built on the external `EntityFrameworkCore.Triggers` -package; register them inside a [Data Mapping](#data-mappings)'s `Configure(builder)`, not in the entity itself. +package; register them inside a [Data Mapping](#data-mappings)'s `ConfigureEntity(builder)`, not in the entity itself. | Trigger | Timing | `TEntity` requires | | ------------------- | ------- | ------------------------ | @@ -3239,18 +3238,17 @@ internal static class MyEntityTriggers public class MyEntityMapping : BaseEntityMapping { - public override void Configure(EntityTypeBuilder builder) + protected override void ConfigureEntity(EntityTypeBuilder builder) { - base.Configure(builder); builder.OnInserting(MyEntityTriggers.Inserting); } } ``` -⚠ **Use a static field/method, not `x => { ... }` written inline in `Configure`.** Registration is deduplicated +⚠ **Use a static field/method, not `x => { ... }` written inline in `ConfigureEntity`.** Registration is deduplicated by delegate equality against a process-wide, per-entity-type registry (`Triggers` is a **static** event, shared across every `DbContext` instance in the app, not scoped per instance) — an inline lambda is a new -delegate reference every time `Configure` runs, defeating the dedup if the model is ever built more than once +delegate reference every time `ConfigureEntity` runs, defeating the dedup if the model is ever built more than once and silently double-registering the trigger. A static field/method is always the same delegate reference, so `AddOnce` correctly registers it exactly once.